From e7a71a39edc06277436e61e20b5a38e857e1def4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:35:28 +0100 Subject: [PATCH 1/5] test(core): pin CERTIFICATE_VALIDITY_MS to its exact millisecond value Nothing previously asserted the constant's actual numeric value, so any operator swap in its 365 * 24 * 60 * 60 * 1000 derivation (e.g. one * becoming /) produced a wrong validity period with every existing test still passing. --- src/core/test/identity.unit.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/core/test/identity.unit.test.ts b/src/core/test/identity.unit.test.ts index aced718..5f0975c 100644 --- a/src/core/test/identity.unit.test.ts +++ b/src/core/test/identity.unit.test.ts @@ -4,7 +4,11 @@ import { describe, it, expect } from "vitest"; import { X509Certificate } from "node:crypto"; -import { generateIdentity, getCertificateFingerprint } from "../identity.js"; +import { + generateIdentity, + getCertificateFingerprint, + CERTIFICATE_VALIDITY_MS, +} from "../identity.js"; describe("generateIdentity", () => { it("returns a valid PeerIdentity with all required fields", () => { @@ -88,6 +92,12 @@ describe("generateIdentity", () => { }); }); +describe("CERTIFICATE_VALIDITY_MS", () => { + it("is exactly 365 days in milliseconds", () => { + expect(CERTIFICATE_VALIDITY_MS).toBe(365 * 24 * 60 * 60 * 1000); + }); +}); + describe("getCertificateFingerprint", () => { it("is deterministic — same cert always produces same fingerprint", () => { const { certificate, fingerprint } = generateIdentity(); From 861348e4e8cbda80e68f519e8744a20d935a3d07 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:35:45 +0100 Subject: [PATCH 2/5] test(core): assert the certificate's X.509 v3 tag and serial byte spread Nothing verified the version field encoded as v3 (context tag [0] EXPLICIT INTEGER 2), so a mutation truncating it to an empty INTEGER (effectively v1) went undetected despite the Subject Alternative Name and Basic Constraints extensions being illegal on a v1 certificate. Separately, the DER-INTEGER minimal-padding fix-up for the serial number's leading byte had an unguarded assignment path that forces that byte to a fixed value regardless of its randomly generated content; nothing previously checked that serial numbers actually vary across identities. --- src/core/test/identity.unit.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/core/test/identity.unit.test.ts b/src/core/test/identity.unit.test.ts index 5f0975c..dcbad8d 100644 --- a/src/core/test/identity.unit.test.ts +++ b/src/core/test/identity.unit.test.ts @@ -90,6 +90,29 @@ 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("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_MS", () => { From cd9ee491cf4f34cdb40babd413cba45ad61fccfd Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:36:05 +0100 Subject: [PATCH 3/5] test(core): assert notBefore/notAfter as an exact zero-padded date Fakes the system clock to a date where every UTCTime component (year mod 100, month, day, minute) is below 10, then checks the certificate's parsed validity window against the exact expected instants. Every existing test ran against the real "now", whose year-mod-100 and month/day/hour/minute happen to already be two digits most of the time, so a missing zero-pad or an inverted getUTCMonth() + 1 went unnoticed as long as no field happened to need padding. --- src/core/test/identity.unit.test.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/core/test/identity.unit.test.ts b/src/core/test/identity.unit.test.ts index dcbad8d..44b74f8 100644 --- a/src/core/test/identity.unit.test.ts +++ b/src/core/test/identity.unit.test.ts @@ -2,7 +2,7 @@ * Unit tests for identity.ts — cryptographic identity generation. */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, afterEach, vi } from "vitest"; import { X509Certificate } from "node:crypto"; import { generateIdentity, @@ -113,6 +113,27 @@ describe("generateIdentity", () => { "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", () => { From c484395dd10733583e68cc84be00ee269c59ea87 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:36:23 +0100 Subject: [PATCH 4/5] test(core): assert rawPublicKeyFromPrivateKey rejects non-EC keys rawPublicKeyFromPrivateKey is exported public API, so a caller can genuinely pass a non-EC private key (RSA, Ed25519) whose JWK export carries no x/y coordinates. Nothing exercised that guard before, so its error message could be mutated to an empty string without any test noticing. --- src/core/test/identity.unit.test.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/core/test/identity.unit.test.ts b/src/core/test/identity.unit.test.ts index 44b74f8..2231894 100644 --- a/src/core/test/identity.unit.test.ts +++ b/src/core/test/identity.unit.test.ts @@ -3,11 +3,12 @@ */ import { describe, it, expect, afterEach, vi } from "vitest"; -import { X509Certificate } from "node:crypto"; +import { X509Certificate, generateKeyPairSync } from "node:crypto"; import { generateIdentity, getCertificateFingerprint, CERTIFICATE_VALIDITY_MS, + rawPublicKeyFromPrivateKey, } from "../identity.js"; describe("generateIdentity", () => { @@ -142,6 +143,20 @@ describe("CERTIFICATE_VALIDITY_MS", () => { }); }); +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", () => { it("is deterministic — same cert always produces same fingerprint", () => { const { certificate, fingerprint } = generateIdentity(); From c63ad846a1ab0ef3576eb9308b55d6c72f631488 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 08:02:55 +0100 Subject: [PATCH 5/5] test(core): assert Basic Constraints criticality and PEM line wrapping Neither the extension's critical flag nor the certificate body's 64-char line wrapping had any assertion: a flipped critical bit or a one-char-per-line PEM body both still parse and load fine via tls.createServer, so the existing loadability-only checks never noticed either regression. --- src/core/test/identity.unit.test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/core/test/identity.unit.test.ts b/src/core/test/identity.unit.test.ts index 2231894..3a4998d 100644 --- a/src/core/test/identity.unit.test.ts +++ b/src/core/test/identity.unit.test.ts @@ -42,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(); @@ -101,6 +119,15 @@ describe("generateIdentity", () => { 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++) {