Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 99 additions & 3 deletions src/core/test/identity.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@
* Unit tests for identity.ts — cryptographic identity generation.
*/

import { describe, it, expect } from "vitest";
import { X509Certificate } from "node:crypto";
import { generateIdentity, getCertificateFingerprint } from "../identity.js";
import { describe, it, expect, afterEach, vi } from "vitest";
import { X509Certificate, generateKeyPairSync } from "node:crypto";
import {
generateIdentity,
getCertificateFingerprint,
CERTIFICATE_VALIDITY_MS,
rawPublicKeyFromPrivateKey,
} from "../identity.js";

describe("generateIdentity", () => {
it("returns a valid PeerIdentity with all required fields", () => {
Expand Down Expand Up @@ -37,6 +42,24 @@ describe("generateIdentity", () => {
);
});

it("wraps the certificate's base64 body at 64 characters per line", () => {
const { certificate } = generateIdentity();
const bodyLines = certificate
.split("\n")
.filter(
(line) =>
line.length > 0 &&
line !== "-----BEGIN CERTIFICATE-----" &&
line !== "-----END CERTIFICATE-----",
);

expect(bodyLines.length).toBeGreaterThan(1);
for (const line of bodyLines.slice(0, -1)) {
expect(line.length).toBe(64);
}
expect(bodyLines.at(-1)?.length).toBeLessThanOrEqual(64);
});

it("produces a fingerprint that is a 95-character SHA-256 hex string with colons", () => {
const { fingerprint } = generateIdentity();

Expand Down Expand Up @@ -86,6 +109,79 @@ describe("generateIdentity", () => {
expect(a.privateKey).not.toBe(b.privateKey);
expect(a.certificate).not.toBe(b.certificate);
});

it("encodes the certificate as X.509 v3 (context tag [0] EXPLICIT INTEGER 2)", () => {
// v3 is required for the Subject Alternative Name / Basic Constraints extensions to be legal at all -- a v1 certificate carrying extensions is malformed, so the version tag is load-bearing even though nothing else in this file reads it back.
const { certificate } = generateIdentity();
const x509 = new X509Certificate(certificate);

const versionField = Buffer.from([0xa0, 0x03, 0x02, 0x01, 0x02]);
expect(x509.raw.indexOf(versionField)).toBeGreaterThanOrEqual(0);
});

it("marks the Basic Constraints extension critical (DER BOOLEAN TRUE)", () => {
// RFC 5280 requires Basic Constraints to be marked critical; a non-critical CA:FALSE constraint is a spec violation that some strict X.509 validators reject outright, even though tls.createServer tolerates it.
const { certificate } = generateIdentity();
const x509 = new X509Certificate(certificate);
const criticalTrue = Buffer.from([0x01, 0x01, 0xff]);

expect(x509.raw.indexOf(criticalTrue)).toBeGreaterThanOrEqual(0);
});

it("does not systematically force the serial number's leading byte to a fixed value", () => {
const firstBytes = new Set<string>();
for (let i = 0; i < 20; i++) {
const { certificate } = generateIdentity();
const x509 = new X509Certificate(certificate);
firstBytes.add(x509.serialNumber.slice(0, 2));
}

expect(
firstBytes.size,
"serial numbers should vary across identities, not collapse onto one leading byte",
).toBeGreaterThan(1);
});

describe("certificate validity window", () => {
afterEach(() => {
vi.useRealTimers();
});

it("encodes notBefore/notAfter as exact zero-padded UTCTime, CERTIFICATE_VALIDITY_MS apart", () => {
// 2005-03-05T07:08:09Z: every date component below 10 (year%100, month, day, minute) so a missing zero-pad or an off-by-one in month arithmetic shifts the parsed date.
const fixedNow = new Date(Date.UTC(2005, 2, 5, 7, 8, 9));
vi.useFakeTimers();
vi.setSystemTime(fixedNow);

const { certificate } = generateIdentity();
const x509 = new X509Certificate(certificate);

expect(x509.validFromDate.toISOString()).toBe(fixedNow.toISOString());
expect(x509.validToDate.getTime() - x509.validFromDate.getTime()).toBe(
CERTIFICATE_VALIDITY_MS,
);
});
});
});

describe("CERTIFICATE_VALIDITY_MS", () => {
it("is exactly 365 days in milliseconds", () => {
expect(CERTIFICATE_VALIDITY_MS).toBe(365 * 24 * 60 * 60 * 1000);
});
});

describe("rawPublicKeyFromPrivateKey", () => {
it("throws for a private key whose JWK export has no EC x/y coordinates", () => {
const { privateKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
privateKeyEncoding: { type: "pkcs8", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
});

expect(() => rawPublicKeyFromPrivateKey(privateKey)).toThrow(
"expected an EC JWK with x/y coordinates",
);
});
});

describe("getCertificateFingerprint", () => {
Expand Down