Skip to content
Merged
Show file tree
Hide file tree
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
115 changes: 115 additions & 0 deletions src/pki/anchor-identity.test.ts
Original file line number Diff line number Diff line change
@@ -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<CryptoKeyPair> => 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<x509.X509Certificate> {
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<typeof makeStore>
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)
})
})
46 changes: 37 additions & 9 deletions src/pki/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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.`
Expand Down Expand Up @@ -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
}
}
Loading