diff --git a/src/core/test/identity.unit.test.ts b/src/core/test/identity.unit.test.ts index aced718..3a4998d 100644 --- a/src/core/test/identity.unit.test.ts +++ b/src/core/test/identity.unit.test.ts @@ -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", () => { @@ -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(); @@ -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(); + 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", () => {