diff --git a/src/pki/anchor-identity.test.ts b/src/pki/anchor-identity.test.ts new file mode 100644 index 0000000..75bc6de --- /dev/null +++ b/src/pki/anchor-identity.test.ts @@ -0,0 +1,115 @@ +import 'reflect-metadata' +import { beforeAll, describe, expect, it } from 'vitest' +import * as x509 from '@peculiar/x509' +import { makeStore, validatePath } from './path' +import { webCrypto } from './parse' + +const ROOT = 'CN=Anchor Identity Test Root' +const FOREIGN = 'CN=Untrusted Cross-Signing Issuer' +const AT = new Date('2026-10-11T00:00:00Z') +const BEFORE = new Date('2026-01-01T00:00:00Z') +const AFTER = new Date('2036-01-01T00:00:00Z') +const bytes = (buffer: ArrayBuffer): number[] => [...new Uint8Array(buffer)] +const keys = (): Promise => webCrypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'], +) + +async function mint( + subject: string, issuer: string, subjectKeys: CryptoKeyPair, issuerKeys: CryptoKeyPair, + ca: boolean, serial: string, notAfter = AFTER, +): Promise { + return x509.X509CertificateGenerator.create({ + subject, issuer, serialNumber: serial, notBefore: BEFORE, notAfter, + signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' }, + publicKey: subjectKeys.publicKey, signingKey: issuerKeys.privateKey, + extensions: [ + new x509.BasicConstraintsExtension(ca, undefined, true), + new x509.KeyUsagesExtension(ca ? x509.KeyUsageFlags.keyCertSign : x509.KeyUsageFlags.digitalSignature, true), + ], + }, webCrypto) +} + +describe('trust anchors require the locally held name and public key', () => { + let heldKeys: CryptoKeyPair, foreignKeys: CryptoKeyPair, attackerKeys: CryptoKeyPair, leafKeys: CryptoKeyPair + let held: x509.X509Certificate + let store: ReturnType + beforeAll(async () => { + ;[heldKeys, foreignKeys, attackerKeys, leafKeys] = await Promise.all([keys(), keys(), keys(), keys()]) + held = await mint(ROOT, ROOT, heldKeys, heldKeys, true, '01') + store = makeStore([{ pem: held.toString('pem'), why: 'Locally configured test anchor' }]) + }) + + it('accepts a leaf genuinely signed by the held root', async () => { + const leaf = await mint('CN=Valid Leaf', ROOT, leafKeys, heldKeys, false, '02') + const result = await validatePath([leaf], store, AT) + expect(result.trusted).toBe(true) + expect(result.reachedAnchor).toBe(true) + expect(result.steps.filter(s => s.fromStore)).toHaveLength(1) + expect(bytes(result.steps.at(-1)!.cert.rawData)).toEqual(bytes(held.rawData)) + }) + + it('rejects a different-key cross-signed top that copies the held root name', async () => { + const top = await mint(ROOT, FOREIGN, attackerKeys, foreignKeys, true, '03') + const leaf = await mint('CN=Attacker Leaf', ROOT, leafKeys, attackerKeys, false, '04') + // Every supplied signature is genuine. The missing fact is authorization + // by the configured key, rather than whether the attacker signed a leaf. + expect(await top.verify({ publicKey: foreignKeys.publicKey }, webCrypto)).toBe(true) + expect(await leaf.verify({ publicKey: top.publicKey }, webCrypto)).toBe(true) + expect(await leaf.verify({ publicKey: held.publicKey }, webCrypto)).toBe(false) + expect(bytes(top.publicKey.rawData)).not.toEqual(bytes(held.publicKey.rawData)) + const result = await validatePath([leaf, top], store, AT) + expect(result.links.every(link => link.verified)).toBe(true) + expect(result.trusted).toBe(false) + expect(result.reachedAnchor).toBe(false) + expect(result.anchorWhy).toBe('') + expect(result.steps.some(step => step.fromStore)).toBe(false) + expect(result.reason).toContain('public key') + }) + + it('still rejects a different-key self-issued top through signature verification', async () => { + const top = await mint(ROOT, ROOT, attackerKeys, attackerKeys, true, '05') + const leaf = await mint('CN=Attacker Leaf', ROOT, leafKeys, attackerKeys, false, '06') + const result = await validatePath([leaf, top], store, AT) + expect(result.trusted).toBe(false) + expect(result.reachedAnchor).toBe(false) + expect(result.links.at(-1)!.verified).toBe(false) + expect(result.reason).toContain('does not check out') + }) + + it('accepts a same-key cross-signed top and reports the actual held certificate', async () => { + const top = await mint(ROOT, FOREIGN, heldKeys, foreignKeys, true, '07') + const leaf = await mint('CN=Valid Leaf', ROOT, leafKeys, heldKeys, false, '08') + expect(bytes(top.rawData)).not.toEqual(bytes(held.rawData)) + expect(bytes(top.publicKey.rawData)).toEqual(bytes(held.publicKey.rawData)) + const result = await validatePath([leaf, top], store, AT) + expect(result.trusted).toBe(true) + expect(result.reachedAnchor).toBe(true) + expect(result.anchorWhy).toBe('Locally configured test anchor') + const anchor = result.steps.at(-1)! + expect(anchor.fromStore).toBe(true) + expect(anchor.role).toBe('root') + expect(bytes(anchor.cert.rawData)).toEqual(bytes(held.rawData)) + expect(bytes(anchor.cert.rawData)).not.toEqual(bytes(top.rawData)) + }) + + it('preserves rejection of an expired served cross-signed copy', async () => { + const top = await mint(ROOT, FOREIGN, heldKeys, foreignKeys, true, '09', new Date('2026-10-01T00:00:00Z')) + const leaf = await mint('CN=Valid Leaf', ROOT, leafKeys, heldKeys, false, '10') + const result = await validatePath([leaf, top], store, AT) + expect(result.trusted).toBe(false) + expect(result.reason).toMatch(/expired|not valid/) + }) + + it('checks the held certificate date under the existing lab anchor-date policy', async () => { + const expiredHeld = await mint(ROOT, ROOT, heldKeys, heldKeys, true, '11', new Date('2026-10-01T00:00:00Z')) + const expiredStore = makeStore([{ pem: expiredHeld.toString('pem'), why: 'Expired local test anchor' }]) + const top = await mint(ROOT, FOREIGN, heldKeys, foreignKeys, true, '12') + const leaf = await mint('CN=Valid Leaf', ROOT, leafKeys, heldKeys, false, '13') + const result = await validatePath([leaf, top], expiredStore, AT) + expect(result.trusted).toBe(false) + expect(result.reason).toContain('expired on 2026-10-01') + const anchor = result.steps.at(-1)! + expect(bytes(anchor.cert.rawData)).toEqual(bytes(expiredHeld.rawData)) + expect(anchor.inDate).toBe(false) + }) +}) diff --git a/src/pki/path.ts b/src/pki/path.ts index 3a89a15..8734dde 100644 --- a/src/pki/path.ts +++ b/src/pki/path.ts @@ -26,7 +26,7 @@ import type { Link, Role } from './types' */ export interface AnchorStore { - /** The anchors, keyed by subject name, as a device store is. */ + /** Subject-name lookup finds candidates; their held public key determines trust. */ readonly anchors: readonly { subject: string; cert: x509.X509Certificate; why: string }[] find(subject: string): { subject: string; cert: x509.X509Certificate; why: string } | undefined } @@ -160,22 +160,35 @@ export async function validatePath( reachedAnchor = link.verified anchorWhy = anchor.why } else if (store.find(top.subject)) { - // The device has this very certificate. The walk is already home: the - // served copy is redundant, which is the ordinary case for a chain that - // ends in a cross-signed root. + // A cross-signed root may have different certificate bytes and an issuer + // outside this store. Its name AND public key must identify the held + // anchor (RFC 5280 6.1.1(d)); copying the name alone grants no authority. const held = store.find(top.subject)! - steps[steps.length - 1] = { ...steps[steps.length - 1]!, role: 'root', fromStore: true } - reachedAnchor = true - anchorWhy = held.why + if (!sameAnchorKey(top, held.cert)) { + reason = reason || `${label(top)} has a name in the trusted list, but its public key does not match the trusted certificate. A matching name alone does not make its key trusted.` + } else { + // Show the actual configured certificate, not the server's different + // cross-signed copy mislabeled as something supplied by the store. + steps[steps.length - 1] = { + ...steps[steps.length - 1]!, role: 'root', name: label(held.cert), + cert: held.cert, fromStore: true, + inDate: validityAt(held.cert, at).inWindow, + } + reachedAnchor = true + anchorWhy = held.why + } } else if (isSelfIssued(top)) { reason = reason || `${label(top)} signed itself, and it is not in the trusted list. Anyone can sign their own certificate, so a signature from the same name it is claiming proves nothing.` } else { reason = reason || `The chain stops at ${label(top)}, which was signed by ${label2(top)} -- and that certificate is neither in the chain nor in the trusted list. The walk has nowhere left to go.` } - const allInDate = steps.every((s) => s.inDate) + // Replacing a redundant top with the actual held anchor must not forget a + // failed date on the served copy. Held-anchor dates remain this lab's local + // teaching policy, matching the ordinary issuer-anchor branch above. + const allInDate = validityAt(top, at).inWindow && steps.every((s) => s.inDate) if (!allInDate && !reason) { - const bad = steps.find((s) => !s.inDate)! + const bad = steps.find((s) => !s.inDate) || { name: label(top), cert: top } const v = validityAt(bad.cert, at) reason = v.side === 'after' ? `${bad.name} expired on ${v.notAfter.toISOString().slice(0, 10)}, which is before the date you are checking.` @@ -237,3 +250,18 @@ async function judgeLink( : `${issuerName}'s signature over ${childName} does not check out. The bytes have been changed since it was signed, or it was never signed by this certificate at all.`, } } + +/** Compare the complete SPKI: key algorithm, parameters and public-key bytes. */ +function sameAnchorKey( + served: x509.X509Certificate, + held: x509.X509Certificate, +): boolean { + try { + const a = new Uint8Array(served.publicKey.rawData) + const b = new Uint8Array(held.publicKey.rawData) + return a.length > 0 && a.length === b.length && a.every((byte, i) => byte === b[i]) + } catch { + // Unreadable identity cannot establish correspondence with a held key. + return false + } +}