/** * A3 (FIX C-native-3) — the SINGLE load-bearing tenant-isolation control, unit-tested under Node. * * `deploy/nginx/njs/getCertSub.js` runs inside nginx (njs) via `js_set $cert_sub getCertSub`. This * suite exercises the SAME source under Node against REAL P-256 leaf certs minted by the A1 issuance * primitive (`assembleCertificate` + `inProcessP256CaSigner`) — the exact structure the B1/A4 issuers * stamp — so a parsing regression is caught in CI, not in production where it would be a skeleton-key. * * Two layers, both here: * 1. `extractLeftmostDnsLabel` — the njs SAN extractor: leftmost dNSName label, fail-closed to ''. * 2. `certHostOk` — a JS mirror of the nginx `map "$cert_sub:$ssl_server_name"` PCRE rule, so the * allow/deny semantics (incl. the cross-tenant NEGATIVE case) are asserted deterministically. */ import 'reflect-metadata' import { describe, test, expect } from 'vitest' import * as x509 from '@peculiar/x509' import { webcrypto, randomBytes } from 'node:crypto' import certsub from '../../deploy/nginx/njs/getCertSub.js' import { assembleCertificate } from '../src/ca/x509-assembler.js' import { inProcessP256CaSigner } from '../src/boot/ca-wiring.js' x509.cryptoProvider.set(webcrypto) const { extractLeftmostDnsLabel, getCertSub } = certsub const DAY_MS = 24 * 60 * 60 * 1000 /** WebCrypto key pair (the `CryptoKeyPair` global is not in this project's TS lib set). */ type KeyPair = { readonly publicKey: CryptoKey; readonly privateKey: CryptoKey } async function genP256(): Promise { return (await webcrypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'], )) as unknown as KeyPair } function derToPem(der: Uint8Array, label = 'CERTIFICATE'): string { const b64 = Buffer.from(der).toString('base64').match(/.{1,64}/g)?.join('\n') ?? '' return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----\n` } type SanEntry = { readonly type: 'dns' | 'url'; readonly value: string } /** * Mint a REAL P-256 leaf PEM off the dev P-256 CA (stand-in for the B1/A4 issuers), carrying exactly * the supplied SAN entries. Empty `san` omits the SAN extension entirely (the no-SAN case). */ async function mintLeafPem(san: readonly SanEntry[]): Promise { const subject = await genP256() const extensions: x509.Extension[] = [] if (san.length > 0) { extensions.push(new x509.SubjectAlternativeNameExtension(san as { type: string; value: string }[])) } extensions.push(new x509.BasicConstraintsExtension(false, undefined, true)) extensions.push(new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.clientAuth])) const now = Date.now() const der = await assembleCertificate({ subjectPublicKey: subject.publicKey, subject: 'CN=leaf', issuer: 'CN=device-CA', serialNumber: randomBytes(16), notBefore: new Date(now - 60_000), notAfter: new Date(now + DAY_MS), extensions, signer: inProcessP256CaSigner(), sigAlg: 'ecdsa-p256', }) return derToPem(der) } const SPIFFE_ALICE = 'spiffe://terminal.yaojia.wang/account/a1/host/alice' describe('extractLeftmostDnsLabel — POSITIVE (real A1-minted leaves, FIX C-native-3)', () => { test("SAN dNSName 'alice.terminal.yaojia.wang' (+ spiffe URI) → 'alice'", async () => { const pem = await mintLeafPem([ { type: 'dns', value: 'alice.terminal.yaojia.wang' }, { type: 'url', value: SPIFFE_ALICE }, ]) expect(extractLeftmostDnsLabel(pem)).toBe('alice') }) test("SAN dNSName 'bob.terminal.yaojia.wang' → 'bob'", async () => { const pem = await mintLeafPem([{ type: 'dns', value: 'bob.terminal.yaojia.wang' }]) expect(extractLeftmostDnsLabel(pem)).toBe('bob') }) test('picks the dNSName even when a URI SAN is listed FIRST (order-independent)', async () => { const pem = await mintLeafPem([ { type: 'url', value: 'spiffe://terminal.yaojia.wang/account/a1/host/charlie' }, { type: 'dns', value: 'charlie.terminal.yaojia.wang' }, ]) expect(extractLeftmostDnsLabel(pem)).toBe('charlie') }) test('takes the FIRST dNSName when multiple are present', async () => { const pem = await mintLeafPem([ { type: 'dns', value: 'dave.terminal.yaojia.wang' }, { type: 'dns', value: 'erin.terminal.yaojia.wang' }, ]) expect(extractLeftmostDnsLabel(pem)).toBe('dave') }) }) describe('extractLeftmostDnsLabel — FAIL-CLOSED (any parse failure / missing SAN → "")', () => { test('a leaf with ONLY a URI SAN (no dNSName) → ""', async () => { const pem = await mintLeafPem([{ type: 'url', value: SPIFFE_ALICE }]) expect(extractLeftmostDnsLabel(pem)).toBe('') }) test('a leaf with NO SAN extension at all → ""', async () => { const pem = await mintLeafPem([]) expect(extractLeftmostDnsLabel(pem)).toBe('') }) test('empty string → ""', () => { expect(extractLeftmostDnsLabel('')).toBe('') }) test('non-PEM garbage → ""', () => { expect(extractLeftmostDnsLabel('this is not a certificate')).toBe('') }) test('valid PEM markers but garbage base64 body → ""', () => { const junk = '-----BEGIN CERTIFICATE-----\nZ m 9 v Y m F y\n-----END CERTIFICATE-----\n' expect(extractLeftmostDnsLabel(junk)).toBe('') }) test('truncated DER inside valid PEM markers → ""', async () => { const pem = await mintLeafPem([{ type: 'dns', value: 'alice.terminal.yaojia.wang' }]) const body = pem.split('\n').slice(1, 3).join('') // keep only the first ~2 base64 lines const truncated = `-----BEGIN CERTIFICATE-----\n${body}\n-----END CERTIFICATE-----\n` expect(extractLeftmostDnsLabel(truncated)).toBe('') }) test('CP5: a nested-length-inconsistent cert cannot leak a dNSName past its parent bound → ""', () => { // Hand-crafted DER: Certificate→TBS→[3]→Extensions→Extension(SAN)→extnValue OCTET STRING whose // GeneralNames SEQUENCE declares length 6 — overrunning the 2-byte extnValue content — so its // "content" reaches into a dNSName ('evil') that actually sits AFTER the OCTET STRING as a sibling // (but still inside the buffer). Without the parent-bound check readTlv would accept the inflated // GeneralNames and return 'evil' (a cross-tenant skeleton-key); bounding each child to its parent's // contentEnd makes the extractor fail-closed to ''. const der = [ 0x30, 0x17, // Certificate SEQUENCE, len 23 0x30, 0x15, // TBSCertificate SEQUENCE, len 21 0xa3, 0x13, // [3] extensions container, len 19 0x30, 0x11, // Extensions SEQUENCE, len 17 0x30, 0x09, // Extension SEQUENCE, len 9 0x06, 0x03, 0x55, 0x1d, 0x11, // OID 2.5.29.17 (subjectAltName) 0x04, 0x02, 0x30, 0x06, // extnValue OCTET STRING (len 2) = GeneralNames header claiming len 6 0x82, 0x04, 0x65, 0x76, 0x69, 0x6c, // dNSName [2] "evil" — sibling, reached only by the lie ] const b64 = Buffer.from(Uint8Array.from(der)).toString('base64') const pem = `-----BEGIN CERTIFICATE-----\n${b64}\n-----END CERTIFICATE-----\n` expect(extractLeftmostDnsLabel(pem)).toBe('') }) }) describe('getCertSub — njs entrypoint glue over r.variables.ssl_client_cert', () => { test('reads ssl_client_cert and returns the leftmost label', async () => { const pem = await mintLeafPem([{ type: 'dns', value: 'alice.terminal.yaojia.wang' }]) expect(getCertSub({ variables: { ssl_client_cert: pem } })).toBe('alice') }) test('missing ssl_client_cert (no client cert) → "" (fail-closed → map denies)', () => { expect(getCertSub({ variables: {} })).toBe('') }) test('tab-mangled PEM (nginx $ssl_client_cert continuation form) still parses', async () => { const pem = await mintLeafPem([{ type: 'dns', value: 'alice.terminal.yaojia.wang' }]) // nginx prepends a TAB to every line except the first in $ssl_client_cert. const tabbed = pem .split('\n') .map((line, i) => (i === 0 ? line : `\t${line}`)) .join('\n') expect(getCertSub({ variables: { ssl_client_cert: tabbed } })).toBe('alice') }) }) /** * JS mirror of the nginx map (zone-anchored, self-sufficient): * map "$cert_sub:$ssl_server_name" $cert_host_ok { "~^(?[^:]+):(?P=s)\\.terminal\\.yaojia\\.wang$" 1; default 0; } * PCRE `(?P=s)` is the named backreference; JS spells the same backreference `\k`. Both engines * require: a non-empty leftmost label before ':', then the SAME text after ':' followed by EXACTLY * `.terminal.yaojia.wang` to the end. Anchoring the full zone (not just `