feat(tunnel): zero-touch tunnel enrollment — control-plane PKI, host agent, iOS, nginx isolation

Customers install one command / log in once; hardware-generated keys never leave the
device; CSRs return certs + subdomain; frpc + base-app run as durable services. No .p12,
no manual cert import. Implements the MVP fast-path of docs/PLAN_TUNNEL_AUTOMATION.md.

Control-plane / PKI (control-plane/):
- ca/x509-assembler.ts: single KMS-signed real X.509 issuance primitive (Ed25519 + P-256)
- ca/csr-ec.ts: P-256 PKCS#10 proof-of-possession (verifyCsrPoPEc) + CSR-key routing
- ca/frpclient-issue.ts, ca/device-issue.ts: P-256 frp-client + device leaf signers
- ca/rotate.ts + api/renew.ts: real-X.509 /renew + /device/:id/renew (mTLS current cert)
- registry/devices.ts: device registry + per-account cap/rate-limit
- auth/session.ts: device:enroll capability token mint/verify
- api/device-enroll.ts: POST /device/enroll (ownership-gated, deny-by-default)
- pairing/native-redeem.ts + shared gateAndConsumePairingCode; api/provision.ts native arm
- boot/native-ca.ts + main.ts: wire two P-256 CAs + issuers + routers (dev / KMS fail-fast)

Contracts: relay-contracts enroll right; relay-auth SPIFFE /device/ arm + spiffeIdFor(kind)

Host agent (agent/):
- transport/frpcToml.ts; provision/frpcBinary.ts + untar.ts (verify-download + traversal-safe extract)
- keys P-256 keygen/CSR/loadIdentity; service two-unit install + BIND_HOST loopback S-GATE
- net/loopbackLiteral.ts strict guard; health/probe.ts + transport/frpSupervise.ts; cli pair --install

iOS (ios/Packages/ClientTLS): SecureEnclaveKey + CertificateSigningRequest + DeviceEnrollmentClient
+ Keychain enroll refactor (SecKey/Security.framework end-to-end, avoids the -25300 trap)

Isolation (deploy/nginx): njs/getCertSub.js SAN parser + zone-anchored map -> 403

Verified: 758 tests green (control-plane 246, agent 267, relay-auth 133, relay-contracts 85,
iOS ClientTLS 27), all tsc clean; real nginx+njs docker 403/200/400; Swift CSR accepted by
the real control-plane verifier; frpc extract byte-identical to `tar -xO`. Cross-validation
caught + fixed 5 real defects (1 critical, 4 high). Remaining = infra (KMS, nginx deploy,
VPS frps, physical iPhone) per PROGRESS_LOG runbook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2026-07-10 16:11:13 +02:00
parent 31054450fc
commit e7f3bd05f0
79 changed files with 9920 additions and 385 deletions

View File

@@ -0,0 +1,97 @@
/**
* A1 acceptance — ECDSA-P256 PKCS#10 parse + proof-of-possession (`ca/csr-ec.ts`), mirroring the
* Ed25519 `ca/csr.ts` tests. Gate (d): a valid P-256 CSR yields `ok:true` with the correct embedded
* pubkey; any tampered / mismatched / non-P256 / malformed CSR yields the UNIFORM `ok:false` result
* with an empty embedded pubkey and no distinguishing detail (fail-closed).
*/
import 'reflect-metadata'
import { describe, test, expect } from 'vitest'
import * as x509 from '@peculiar/x509'
import { webcrypto } from 'node:crypto'
import { verifyCsrPoPEc, buildCsrEc } from '../src/ca/csr-ec.js'
x509.cryptoProvider.set(webcrypto)
/** WebCrypto key pair (the `CryptoKeyPair` global is not in this project's TS lib set). */
type KeyPair = { readonly publicKey: CryptoKey; readonly privateKey: CryptoKey }
describe('verifyCsrPoPEc — gate (d): valid P-256 CSR', () => {
test('a valid P-256 CSR → ok:true with the embedded SPKI matching the generating key', async () => {
const { der, keys } = await buildCsrEc('CN=alice')
const res = await verifyCsrPoPEc(der)
expect(res.ok).toBe(true)
expect(res.embeddedPubSpki.length).toBeGreaterThan(0)
const exported = new Uint8Array(await webcrypto.subtle.exportKey('spki', keys.publicKey))
expect(Buffer.from(res.embeddedPubSpki).equals(Buffer.from(exported))).toBe(true)
})
test('the embedded SPKI re-imports as an EC P-256 verifying key', async () => {
const { der } = await buildCsrEc('CN=bob')
const { embeddedPubSpki } = await verifyCsrPoPEc(der)
const key = await webcrypto.subtle.importKey('spki', new Uint8Array(embeddedPubSpki), { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'])
expect(key.type).toBe('public')
})
})
describe('verifyCsrPoPEc — fail-closed & uniform rejection', () => {
test('a tampered CSR (flipped signature byte) → ok:false uniform', async () => {
const { der } = await buildCsrEc('CN=carol')
const tampered = new Uint8Array(der)
tampered[tampered.length - 5]! ^= 0xff
const res = await verifyCsrPoPEc(tampered)
expect(res).toEqual({ ok: false, embeddedPubSpki: new Uint8Array(0) })
})
test('a CSR whose embedded key was swapped (PoP mismatch) → ok:false uniform', async () => {
// Splice: take request-A's body but leave request-B's signature by concatenating mismatched
// parts is fragile; instead flip several bytes inside the public-key region to break PoP.
const { der } = await buildCsrEc('CN=dave')
const mangled = new Uint8Array(der)
// Corrupt a byte early in the structure (subject/pubkey area) so the self-signature no longer
// matches the tbs — still parseable DER shape but PoP fails.
mangled[25]! ^= 0xff
const res = await verifyCsrPoPEc(mangled)
expect(res).toEqual({ ok: false, embeddedPubSpki: new Uint8Array(0) })
})
test('malformed / non-DER garbage → ok:false uniform (no throw)', async () => {
const res = await verifyCsrPoPEc(Uint8Array.from([1, 2, 3, 4, 5]))
expect(res).toEqual({ ok: false, embeddedPubSpki: new Uint8Array(0) })
})
test('empty input → ok:false uniform', async () => {
const res = await verifyCsrPoPEc(new Uint8Array(0))
expect(res).toEqual({ ok: false, embeddedPubSpki: new Uint8Array(0) })
})
test('a valid non-P256 CSR (Ed25519 key) → ok:false uniform (curve enforced)', async () => {
const keys = (await webcrypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as KeyPair
const csr = await x509.Pkcs10CertificateRequestGenerator.create({
name: 'CN=ed-device',
keys,
signingAlgorithm: { name: 'Ed25519' },
})
const res = await verifyCsrPoPEc(new Uint8Array(csr.rawData))
expect(res).toEqual({ ok: false, embeddedPubSpki: new Uint8Array(0) })
})
test('a valid P-384 CSR → ok:false uniform (only P-256 accepted)', async () => {
const keys = (await webcrypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-384' }, true, ['sign', 'verify'])) as unknown as KeyPair
const csr = await x509.Pkcs10CertificateRequestGenerator.create({
name: 'CN=p384-device',
keys,
signingAlgorithm: { name: 'ECDSA', hash: 'SHA-384' },
})
const res = await verifyCsrPoPEc(new Uint8Array(csr.rawData))
expect(res).toEqual({ ok: false, embeddedPubSpki: new Uint8Array(0) })
})
test('CP3: each rejection returns a FRESH failure object (no shared module-level singleton)', async () => {
const a = await verifyCsrPoPEc(new Uint8Array(0))
const b = await verifyCsrPoPEc(new Uint8Array(0))
expect(a).toEqual({ ok: false, embeddedPubSpki: new Uint8Array(0) })
// Distinct instances (result AND its embedded array) so one caller can never mutate another's.
expect(a).not.toBe(b)
expect(a.embeddedPubSpki).not.toBe(b.embeddedPubSpki)
})
})

View File

@@ -0,0 +1,260 @@
/**
* A4 — POST /device/enroll + /device/attest/challenge (fastify inject, mirrors test/api.test.ts).
* Proves: a valid device:enroll bearer + valid P-256 CSR → 201 with a real X.509 leaf; missing /
* invalid / wrong-right bearers → 401/403 (uniform); over-cap → 429; a malformed CSR → uniform
* reject; the attest challenge stub returns a bound challenge. The bearer is verified through the
* SAME injected `CapabilityVerifier` seam the admin API uses (boot/verifier.ts in production).
*/
import 'reflect-metadata'
import { describe, test, expect, beforeEach } from 'vitest'
import Fastify, { type FastifyInstance } from 'fastify'
import * as x509 from '@peculiar/x509'
import { webcrypto } from 'node:crypto'
import type { CapabilityToken, CapabilityRight } from 'relay-contracts'
import type { CapabilityVerifier } from '../src/api/authz.js'
import { buildCsrEc } from '../src/ca/csr-ec.js'
import { inProcessP256CaSigner } from '../src/boot/ca-wiring.js'
import { createDeviceLeafSigner } from '../src/ca/device-issue.js'
import { createDeviceRegistry, createMemoryDeviceStore } from '../src/registry/devices.js'
import {
buildDeviceEnrollRouter,
type DeviceEnrollDeps,
type SubdomainOwnershipResolver,
} from '../src/api/device-enroll.js'
import { DEVICE_ENROLL_AUD } from '../src/auth/session.js'
x509.cryptoProvider.set(webcrypto)
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
const ACCOUNT_B = '22222222-2222-4222-8222-222222222222'
// Fake verifier mirroring api.test.ts: 'enrollA'/'enrollB'→enroll right; 'attachA'→attach only; else reject.
const verifier: CapabilityVerifier = {
async verify(raw, expectedAud, now): Promise<CapabilityToken> {
const base = { aud: expectedAud, host: 'x', iat: now, exp: now + 600, jti: `jti-${raw}` }
if (raw === 'enrollA') return { ...base, sub: ACCOUNT_A, rights: ['enroll'] as CapabilityRight[] }
if (raw === 'enrollB') return { ...base, sub: ACCOUNT_B, rights: ['enroll'] as CapabilityRight[] }
if (raw === 'attachA') return { ...base, sub: ACCOUNT_A, rights: ['attach'] as CapabilityRight[] }
throw new Error('invalid token')
},
}
// Ownership source of truth (host-onboarding registry in production). ACCOUNT_A owns 'alice' + 'bob';
// everything else is unclaimed → deny-by-default.
const SUBDOMAIN_OWNERS: Readonly<Record<string, string>> = { alice: ACCOUNT_A, bob: ACCOUNT_A }
const ownership: SubdomainOwnershipResolver = {
async ownerOfSubdomain(sub) {
return SUBDOMAIN_OWNERS[sub] ?? null
},
}
async function csrBody(subdomain = 'alice'): Promise<Record<string, unknown>> {
const { der } = await buildCsrEc('CN=web-terminal-device')
return { csr: Buffer.from(der).toString('base64'), keyAlg: 'ec-p256', subdomain, deviceName: 'iphone' }
}
// The registry and the leaf signer MUST share ONE DeviceStore so the signer's gate finds the
// device the route just registered (mirrors host store sharing between registry + createRealLeafSigner).
function buildApp(opts: { deviceCap?: number; rateMax?: number } = {}): FastifyInstance {
const ca = inProcessP256CaSigner()
const store = createMemoryDeviceStore()
const deps: DeviceEnrollDeps = {
verifier,
ownership,
devices: createDeviceRegistry({ devices: store, deviceCap: opts.deviceCap ?? 5, rateMax: opts.rateMax ?? 50 }),
signer: createDeviceLeafSigner({
signer: ca,
issuer: 'CN=device-CA',
caChainDer: [ca.publicKeyRaw],
sanBaseDomain: 'terminal.yaojia.wang',
trustDomain: 'example.com',
devices: store,
}),
}
const app = Fastify({ logger: false })
void app.register(buildDeviceEnrollRouter(deps))
return app
}
let app: FastifyInstance
beforeEach(async () => {
app = buildApp()
await app.ready()
})
describe('A4 POST /device/enroll', () => {
test('valid enroll bearer + valid P-256 CSR → 201 with a real cert', async () => {
const res = await app.inject({
method: 'POST',
url: '/device/enroll',
headers: { authorization: 'Bearer enrollA' },
payload: await csrBody(),
})
expect(res.statusCode).toBe(201)
const body = JSON.parse(res.body)
expect(body.deviceId.length).toBeGreaterThan(0)
expect(Array.isArray(body.caChain)).toBe(true)
expect(typeof body.notAfter).toBe('string')
expect(typeof body.renewAfter).toBe('string')
// the returned cert is a real, re-parseable X.509 leaf
const der = new Uint8Array(Buffer.from(body.cert, 'base64'))
const leaf = new x509.X509Certificate(der)
const san = leaf.getExtension(x509.SubjectAlternativeNameExtension)
expect(san!.names.toJSON()).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' })
})
test('missing bearer → 401', async () => {
const res = await app.inject({ method: 'POST', url: '/device/enroll', payload: await csrBody() })
expect(res.statusCode).toBe(401)
})
test('invalid bearer → 401', async () => {
const res = await app.inject({
method: 'POST',
url: '/device/enroll',
headers: { authorization: 'Bearer bogus' },
payload: await csrBody(),
})
expect(res.statusCode).toBe(401)
})
test('wrong-right bearer (attach, not enroll) → 403', async () => {
const res = await app.inject({
method: 'POST',
url: '/device/enroll',
headers: { authorization: 'Bearer attachA' },
payload: await csrBody(),
})
expect(res.statusCode).toBe(403)
})
test('malformed body → 400 (Zod at the boundary)', async () => {
const res = await app.inject({
method: 'POST',
url: '/device/enroll',
headers: { authorization: 'Bearer enrollA' },
payload: { csr: '', keyAlg: 'rsa', subdomain: 'alice' },
})
expect(res.statusCode).toBe(400)
})
test('malformed CSR (valid shape, bad DER) → uniform reject 400', async () => {
const res = await app.inject({
method: 'POST',
url: '/device/enroll',
headers: { authorization: 'Bearer enrollA' },
payload: { csr: Buffer.from([0, 1, 2, 3]).toString('base64'), keyAlg: 'ec-p256', subdomain: 'alice', deviceName: 'x' },
})
expect(res.statusCode).toBe(400)
})
test('over per-account device cap → 429', async () => {
const capped = buildApp({ deviceCap: 1, rateMax: 50 })
await capped.ready()
const ok = await capped.inject({
method: 'POST',
url: '/device/enroll',
headers: { authorization: 'Bearer enrollA' },
payload: await csrBody('alice'),
})
expect(ok.statusCode).toBe(201)
const over = await capped.inject({
method: 'POST',
url: '/device/enroll',
headers: { authorization: 'Bearer enrollA' },
payload: await csrBody('bob'),
})
expect(over.statusCode).toBe(429)
})
test('over per-account rate-limit → 429', async () => {
const limited = buildApp({ deviceCap: 50, rateMax: 1 })
await limited.ready()
const first = await limited.inject({
method: 'POST',
url: '/device/enroll',
headers: { authorization: 'Bearer enrollA' },
payload: await csrBody('alice'),
})
expect(first.statusCode).toBe(201)
const second = await limited.inject({
method: 'POST',
url: '/device/enroll',
headers: { authorization: 'Bearer enrollA' },
payload: await csrBody('bob'),
})
expect(second.statusCode).toBe(429)
})
// ── Tenant-isolation gate (FIX C-native-3 / H-native-4) ──────────────────────────────────────────
test('account B requesting account A\'s subdomain → 403, no cert issued', async () => {
const res = await app.inject({
method: 'POST',
url: '/device/enroll',
headers: { authorization: 'Bearer enrollB' }, // valid enroll bearer for a DIFFERENT account
payload: await csrBody('alice'), // owned by ACCOUNT_A
})
expect(res.statusCode).toBe(403)
expect(JSON.parse(res.body).cert).toBeUndefined() // rejected before registration + issuance
})
test('unclaimed subdomain → 403 (uniform with foreign-owned: no existence oracle)', async () => {
const res = await app.inject({
method: 'POST',
url: '/device/enroll',
headers: { authorization: 'Bearer enrollA' },
payload: await csrBody('nobody'), // ACCOUNT_A does not own it
})
expect(res.statusCode).toBe(403)
})
test('reserved infra label (admin) → 400, never reaches a certificate SAN', async () => {
const res = await app.inject({
method: 'POST',
url: '/device/enroll',
headers: { authorization: 'Bearer enrollA' },
payload: await csrBody('admin'),
})
expect(res.statusCode).toBe(400)
})
test('subdomain that normalizes to an invalid label → 400', async () => {
const res = await app.inject({
method: 'POST',
url: '/device/enroll',
headers: { authorization: 'Bearer enrollA' },
payload: await csrBody('___'), // strips to empty → not a valid RFC-1123 label
})
expect(res.statusCode).toBe(400)
})
test('account A\'s own subdomain still enrolls (ownership gate does not affect the owner)', async () => {
const res = await app.inject({
method: 'POST',
url: '/device/enroll',
headers: { authorization: 'Bearer enrollA' },
payload: await csrBody('alice'),
})
expect(res.statusCode).toBe(201)
})
})
describe('A4 POST /device/attest/challenge (stub)', () => {
test('valid bearer → 200 with a challenge + expires_in 120', async () => {
const res = await app.inject({
method: 'POST',
url: '/device/attest/challenge',
headers: { authorization: 'Bearer enrollA' },
})
expect(res.statusCode).toBe(200)
const body = JSON.parse(res.body)
expect(typeof body.challenge).toBe('string')
expect(body.challenge.length).toBeGreaterThan(0)
expect(body.expires_in).toBe(120)
})
test('missing bearer → 401', async () => {
const res = await app.inject({ method: 'POST', url: '/device/attest/challenge' })
expect(res.statusCode).toBe(401)
})
})

View File

@@ -0,0 +1,133 @@
/**
* A4 (FIX C-2) — P-256 device leaf issuance off the device-CA via `assembleCertificate`. Proves the
* leaf re-parses + verifies against the device-CA public key; carries SAN dNSName `<sub>.terminal.yaojia.wang`
* + the device SPIFFE URI (`.../device/<did>`, parseable by relay-auth as kind 'device'); is
* clientAuth / CA:false; and that the registry gate rejects uniformly (unknown / revoked / pubkey mismatch).
*/
import 'reflect-metadata'
import { describe, test, expect } from 'vitest'
import * as x509 from '@peculiar/x509'
import { webcrypto } from 'node:crypto'
import { parseSpiffeId } from 'relay-auth/src/agent/spiffe.js'
import { buildCsrEc } from '../src/ca/csr-ec.js'
import { inProcessP256CaSigner } from '../src/boot/ca-wiring.js'
import { createDeviceLeafSigner, DeviceLeafSignError } from '../src/ca/device-issue.js'
import { createMemoryDeviceStore, type DeviceRecord } from '../src/registry/devices.js'
x509.cryptoProvider.set(webcrypto)
const SAN_BASE = 'terminal.yaojia.wang'
const TRUST_DOMAIN = 'example.com'
const ACCOUNT_A = 'a1'
async function spkiOf(pub: CryptoKey): Promise<Uint8Array> {
return new Uint8Array(await webcrypto.subtle.exportKey('spki', pub))
}
function record(overrides: Partial<DeviceRecord>): DeviceRecord {
const now = Date.now()
return {
deviceId: 'dev-1',
accountId: ACCOUNT_A,
subdomainScope: 'alice',
ecPubkeySpki: new Uint8Array([1, 2, 3]),
serial: '0102030405060708090a0b0c0d0e0f10',
status: 'active',
notAfter: new Date(now + 24 * 60 * 60 * 1000).toISOString(),
attestationLevel: 'none',
createdAt: new Date(now).toISOString(),
revokedAt: null,
...overrides,
}
}
async function fixture() {
const ca = inProcessP256CaSigner()
const store = createMemoryDeviceStore()
const signer = createDeviceLeafSigner({
signer: ca,
issuer: 'CN=device-CA',
caChainDer: [ca.publicKeyRaw],
sanBaseDomain: SAN_BASE,
trustDomain: TRUST_DOMAIN,
devices: store,
})
const { der: csr, keys } = await buildCsrEc('CN=web-terminal-device')
const embeddedSpki = await spkiOf(keys.publicKey)
return { ca, store, signer, csr, embeddedSpki }
}
async function importP256Public(spkiDer: Uint8Array): Promise<CryptoKey> {
return webcrypto.subtle.importKey('spki', new Uint8Array(spkiDer), { name: 'ECDSA', namedCurve: 'P-256' }, true, [
'verify',
])
}
describe('A4 device-issue — real P-256 device leaf', () => {
test('leaf re-parses and verifies against the device-CA public key', async () => {
const { ca, store, signer, csr, embeddedSpki } = await fixture()
await store.insert(record({ deviceId: 'dev-1', ecPubkeySpki: embeddedSpki }))
const { cert } = await signer.signDeviceLeaf('dev-1', csr)
const leaf = new x509.X509Certificate(cert)
expect(() => new x509.X509Certificate(cert)).not.toThrow()
const caPub = await importP256Public(ca.publicKeyRaw)
expect(await leaf.verify({ publicKey: caPub, signatureOnly: true })).toBe(true)
})
test('SAN carries dNSName <sub>.terminal.yaojia.wang + the device SPIFFE URI', async () => {
const { store, signer, csr, embeddedSpki } = await fixture()
await store.insert(record({ deviceId: 'dev-1', accountId: ACCOUNT_A, subdomainScope: 'alice', ecPubkeySpki: embeddedSpki }))
const { cert } = await signer.signDeviceLeaf('dev-1', csr)
const san = new x509.X509Certificate(cert).getExtension(x509.SubjectAlternativeNameExtension)
const names = san!.names.toJSON()
expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' })
const uri = names.find((n) => n.type === 'url')!.value
const parsed = parseSpiffeId(uri)
expect(parsed).toEqual({ accountId: ACCOUNT_A, kind: 'device', id: 'dev-1' })
})
test('leaf is EKU clientAuth and CA:false', async () => {
const { store, signer, csr, embeddedSpki } = await fixture()
await store.insert(record({ deviceId: 'dev-1', ecPubkeySpki: embeddedSpki }))
const { cert } = await signer.signDeviceLeaf('dev-1', csr)
const leaf = new x509.X509Certificate(cert)
const bc = leaf.getExtension(x509.BasicConstraintsExtension)
expect(bc!.ca).toBe(false)
const eku = leaf.getExtension(x509.ExtendedKeyUsageExtension)
expect(eku!.usages).toContain(x509.ExtendedKeyUsage.clientAuth)
})
test('result reports serial + validity from the gated record', async () => {
const { store, signer, csr, embeddedSpki } = await fixture()
const rec = record({ deviceId: 'dev-1', ecPubkeySpki: embeddedSpki })
await store.insert(rec)
const res = await signer.signDeviceLeaf('dev-1', csr)
expect(res.serial).toBe(rec.serial)
expect(res.notAfter.toISOString()).toBe(rec.notAfter)
expect(res.notBefore.getTime()).toBeLessThan(Date.now())
})
test('gate rejects an UNKNOWN device uniformly', async () => {
const { signer, csr } = await fixture()
await expect(signer.signDeviceLeaf('ghost', csr)).rejects.toBeInstanceOf(DeviceLeafSignError)
})
test('gate rejects a REVOKED device uniformly', async () => {
const { store, signer, csr, embeddedSpki } = await fixture()
await store.insert(record({ deviceId: 'dev-1', status: 'revoked', revokedAt: new Date().toISOString(), ecPubkeySpki: embeddedSpki }))
await expect(signer.signDeviceLeaf('dev-1', csr)).rejects.toBeInstanceOf(DeviceLeafSignError)
})
test('gate rejects a pubkey MISMATCH (CSR key ≠ registered key) uniformly', async () => {
const { store, signer, csr } = await fixture()
// register with a DIFFERENT pubkey than the CSR embeds
await store.insert(record({ deviceId: 'dev-1', ecPubkeySpki: new Uint8Array([7, 7, 7, 7]) }))
await expect(signer.signDeviceLeaf('dev-1', csr)).rejects.toBeInstanceOf(DeviceLeafSignError)
})
test('gate rejects a malformed CSR uniformly', async () => {
const { store, signer, embeddedSpki } = await fixture()
await store.insert(record({ deviceId: 'dev-1', ecPubkeySpki: embeddedSpki }))
await expect(signer.signDeviceLeaf('dev-1', new Uint8Array([0, 1, 2, 3]))).rejects.toBeInstanceOf(DeviceLeafSignError)
})
})

View File

@@ -0,0 +1,255 @@
/**
* B1 acceptance (FIX H-host-2, H-host-4) — the P-256 HOST frp-client leaf signer. Proves an issued
* leaf is a REAL, tool-parseable, signature-verifiable X.509 v3 cert off the P-256 frp-client-CA,
* carrying the dNSName ENFORCEMENT key + the host SPIFFE-ID (which relay-auth's parser accepts),
* EKU clientAuth + CA:false; that the registry gate rejects unregistered / revoked / pubkey-mismatch
* UNIFORMLY without ever invoking the CA signer; and that a P-256 CSR built by the AGENT's own
* `enroll/csr.ts` passes `verifyCsrPoPEc` and flows its embedded pubkey through to issuance.
*/
import 'reflect-metadata'
import { describe, test, expect, vi } from 'vitest'
import * as x509 from '@peculiar/x509'
import { AsnConvert } from '@peculiar/asn1-schema'
import { Certificate } from '@peculiar/asn1-x509'
import { webcrypto, generateKeyPairSync, randomUUID } from 'node:crypto'
import { parseSpiffeId, spiffeIdFor } from 'relay-auth/src/agent/spiffe.js'
import { createMemoryStores } from '../src/store/memory.js'
import { createHostRegistry } from '../src/registry/hosts.js'
import { fingerprint } from '../src/ca/fingerprint.js'
import { assembleCertificate } from '../src/ca/x509-assembler.js'
import { buildCsrEc, verifyCsrPoPEc } from '../src/ca/csr-ec.js'
import { inProcessP256CaSigner, type CaSigner } from '../src/boot/ca-wiring.js'
import { createFrpClientLeafSigner } from '../src/ca/frpclient-issue.js'
import { LeafSignError } from '../src/ca/sign.js'
// Cross-track proof (vi): drive the ACTUAL agent-side P-256 identity + CSR encoder.
import { generateP256Identity } from '../../agent/src/keys/identity.js'
import { buildCsr as buildAgentCsr } from '../../agent/src/enroll/csr.js'
x509.cryptoProvider.set(webcrypto)
const DAY_MS = 24 * 60 * 60 * 1000
const TRUST_DOMAIN = 'terminal.yaojia.wang'
const DNS_ZONE = 'terminal.yaojia.wang'
interface FrpClientCa {
readonly caSigner: CaSigner
readonly caCert: x509.X509Certificate
readonly caDer: Uint8Array
}
/** Self-signed P-256 `frp-client-CA` (subject key == signer key) so leaves chain to a real CA. */
async function makeFrpClientCa(): Promise<FrpClientCa> {
const caKeys = generateKeyPairSync('ec', { namedCurve: 'P-256' })
const caSpki = new Uint8Array(caKeys.publicKey.export({ format: 'der', type: 'spki' }))
const caSigner = inProcessP256CaSigner(caKeys.privateKey)
const now = Date.now()
const caDer = await assembleCertificate({
subjectPublicKey: caSpki,
subject: 'CN=frp-client-CA',
issuer: 'CN=frp-client-CA',
serialNumber: Uint8Array.from([0x01]),
notBefore: new Date(now - DAY_MS),
notAfter: new Date(now + 365 * DAY_MS),
extensions: [
new x509.BasicConstraintsExtension(true, undefined, true),
new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true),
],
signer: caSigner,
sigAlg: 'ecdsa-p256',
})
return { caSigner, caCert: new x509.X509Certificate(caDer), caDer }
}
async function importP256Public(spkiDer: Uint8Array): Promise<CryptoKey> {
return webcrypto.subtle.importKey('spki', new Uint8Array(spkiDer), { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'])
}
async function spkiOf(pub: CryptoKey): Promise<Uint8Array> {
return new Uint8Array(await webcrypto.subtle.exportKey('spki', pub))
}
/** A memory-store host registry with a P-256 host bound under `subdomain`, plus its CSR + SPKI. */
async function boundHost(subdomain = 'alice') {
const stores = createMemoryStores()
const hosts = createHostRegistry({ hosts: stores.hosts })
const { der: csr, keys } = await buildCsrEc(`CN=${subdomain}`)
const spki = await spkiOf(keys.publicKey)
const accountId = randomUUID()
const host = await hosts.bindHost({ accountId, subdomain, agentPubkey: spki, enrollFpr: fingerprint(spki) })
return { stores, hosts, host, csr, spki, accountId }
}
function makeSigner(ca: FrpClientCa, stores: ReturnType<typeof createMemoryStores>) {
return createFrpClientLeafSigner({
hosts: stores.hosts,
signer: ca.caSigner,
issuerName: ca.caCert.subjectName,
caChainDer: [ca.caDer],
trustDomain: TRUST_DOMAIN,
dnsZone: DNS_ZONE,
})
}
/** Assert a promise rejects with a `LeafSignError` and return it so the caller can check `.code`. */
async function expectLeafSignError(p: Promise<unknown>): Promise<LeafSignError> {
const err = await p.then(
() => {
throw new Error('expected the promise to reject with LeafSignError')
},
(e: unknown) => e,
)
expect(err).toBeInstanceOf(LeafSignError)
return err as LeafSignError
}
describe('frpclient-issue — (i) issued leaf re-parses as X.509 & (ii) chains to the frp-client-CA', () => {
test('leaf re-parses via new x509.X509Certificate and its signature verifies against the CA pubkey', async () => {
const ca = await makeFrpClientCa()
const { stores, host, csr, spki } = await boundHost()
const { cert, caChain } = await makeSigner(ca, stores).signHostLeaf(host.hostId, spki, csr)
expect(() => new x509.X509Certificate(cert)).not.toThrow()
const leaf = new x509.X509Certificate(cert)
expect(leaf.issuer).toBe(ca.caCert.subject) // issuer DN == CA subject DN (chain sanity)
const caPub = await importP256Public(ca.caSigner.publicKeyRaw)
expect(await leaf.verify({ publicKey: caPub, signatureOnly: true })).toBe(true)
// negative: does NOT verify under a different CA key
const otherCa = await importP256Public(inProcessP256CaSigner().publicKeyRaw)
expect(await leaf.verify({ publicKey: otherCa, signatureOnly: true })).toBe(false)
expect(caChain).toEqual([ca.caDer])
})
test('subject key is the enrolled host P-256 key (SPKI byte-equal)', async () => {
const ca = await makeFrpClientCa()
const { stores, host, csr, spki } = await boundHost()
const { cert } = await makeSigner(ca, stores).signHostLeaf(host.hostId, spki, csr)
const leaf = new x509.X509Certificate(cert)
expect(Buffer.from(leaf.publicKey.rawData).equals(Buffer.from(spki))).toBe(true)
})
})
describe('frpclient-issue — (iii) SAN carries dNSName + host SPIFFE URI', () => {
test("SAN = { dNSName '<sub>.terminal.yaojia.wang', URI host-SPIFFE } and parseSpiffeId accepts it as kind host", async () => {
const ca = await makeFrpClientCa()
const { stores, host, csr, spki, accountId } = await boundHost('alice')
const { cert } = await makeSigner(ca, stores).signHostLeaf(host.hostId, spki, csr)
const leaf = new x509.X509Certificate(cert)
const san = leaf.getExtension(x509.SubjectAlternativeNameExtension)
const names = san!.names.toJSON()
expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' })
const uri = names.find((n) => n.type === 'url')!.value
expect(uri).toBe(spiffeIdFor(accountId, 'alice', TRUST_DOMAIN, 'host'))
// relay-auth's OWN parser accepts the URI as a host identity (never drifts from the verifier).
expect(parseSpiffeId(uri)).toEqual({ accountId, id: 'alice', kind: 'host' })
})
})
describe('frpclient-issue — (iv) EKU clientAuth + CA:false + KeyUsage digitalSignature', () => {
test('leaf is a client-auth end-entity cert', async () => {
const ca = await makeFrpClientCa()
const { stores, host, csr, spki } = await boundHost()
const { cert } = await makeSigner(ca, stores).signHostLeaf(host.hostId, spki, csr)
const leaf = new x509.X509Certificate(cert)
const bc = leaf.getExtension(x509.BasicConstraintsExtension)
expect(bc!.ca).toBe(false)
const eku = leaf.getExtension(x509.ExtendedKeyUsageExtension)
expect(eku!.usages).toContain(x509.ExtendedKeyUsage.clientAuth)
const ku = leaf.getExtension(x509.KeyUsagesExtension)
expect(ku!.usages & x509.KeyUsageFlags.digitalSignature).not.toBe(0)
// signatureAlgorithm is ecdsa-with-SHA256 (a P-256 leaf, not Ed25519).
const parsed = AsnConvert.parse(cert.buffer.slice(cert.byteOffset, cert.byteOffset + cert.byteLength) as ArrayBuffer, Certificate)
expect(parsed.signatureAlgorithm.algorithm).toBe('1.2.840.10045.4.3.2')
})
})
describe('frpclient-issue — (v) registry gate rejects uniformly, CA signer never invoked', () => {
test('unregistered host → LeafSignError(not_registered), sign() never called', async () => {
const ca = await makeFrpClientCa()
const signSpy = vi.spyOn(ca.caSigner, 'sign')
const { stores, csr, spki } = await boundHost()
const err = await expectLeafSignError(makeSigner(ca, stores).signHostLeaf(randomUUID(), spki, csr))
expect(err.code).toBe('not_registered')
expect(signSpy).not.toHaveBeenCalled()
})
test('revoked host → LeafSignError(not_registered), sign() never called', async () => {
const ca = await makeFrpClientCa()
const signSpy = vi.spyOn(ca.caSigner, 'sign')
const { stores, hosts, host, csr, spki } = await boundHost()
await hosts.setHostStatus(host.hostId, 'revoked')
const err = await expectLeafSignError(makeSigner(ca, stores).signHostLeaf(host.hostId, spki, csr))
expect(err.code).toBe('not_registered')
expect(signSpy).not.toHaveBeenCalled()
})
test('registry pubkey mismatch (present a different key than registered) → not_registered', async () => {
const ca = await makeFrpClientCa()
const signSpy = vi.spyOn(ca.caSigner, 'sign')
const { stores, host } = await boundHost() // host registered under key A
// A DIFFERENT P-256 key B — CSR PoP + substitution checks pass (embedded == presented), but the
// registry stored key A, so step 3 rejects.
const { der: csrB, keys: keysB } = await buildCsrEc('CN=alice')
const spkiB = await spkiOf(keysB.publicKey)
const err = await expectLeafSignError(makeSigner(ca, stores).signHostLeaf(host.hostId, spkiB, csrB))
expect(err.code).toBe('not_registered')
expect(signSpy).not.toHaveBeenCalled()
})
test('bad CSR proof-of-possession → LeafSignError(csr_rejected), sign() never called', async () => {
const ca = await makeFrpClientCa()
const signSpy = vi.spyOn(ca.caSigner, 'sign')
const { stores, host, csr, spki } = await boundHost()
const forged = Uint8Array.from(csr)
forged[forged.length - 1] = forged[forged.length - 1]! ^ 0xff // corrupt the signature
const err = await expectLeafSignError(makeSigner(ca, stores).signHostLeaf(host.hostId, spki, forged))
expect(err.code).toBe('csr_rejected')
expect(signSpy).not.toHaveBeenCalled()
})
test('all rejects share the SAME uniform message (never leaks which check failed)', async () => {
const ca = await makeFrpClientCa()
const { stores, hosts, host, csr, spki } = await boundHost()
const signer = makeSigner(ca, stores)
const unregistered = await expectLeafSignError(signer.signHostLeaf(randomUUID(), spki, csr))
await hosts.setHostStatus(host.hostId, 'revoked')
const revoked = await expectLeafSignError(signer.signHostLeaf(host.hostId, spki, csr))
expect(unregistered.message).toBe(revoked.message)
expect(unregistered.message).toBe('leaf signing refused')
})
})
describe('frpclient-issue — (vi) an AGENT-built P-256 CSR passes verifyCsrPoPEc & issues', () => {
test('agent enroll/csr.ts CSR: verifyCsrPoPEc ok + embedded pubkey flows into the leaf', async () => {
// Drive the REAL agent-side encoder (not the control-plane test helper).
const id = generateP256Identity()
const agentCsrPem = buildAgentCsr(id, 'alice.terminal.yaojia.wang')
const agentCsrDer = new Uint8Array(
Buffer.from(agentCsrPem.replace(/-----[A-Z ]+-----/g, '').replace(/\s+/g, ''), 'base64'),
)
// (a) the control-plane P-256 PoP verifier accepts the agent's hand-rolled CSR.
const pop = await verifyCsrPoPEc(agentCsrDer)
expect(pop.ok).toBe(true)
// its embedded pubkey is exactly the agent identity's EC SPKI.
expect(Buffer.from(pop.embeddedPubSpki).equals(Buffer.from(id.publicKey))).toBe(true)
// (b) that same pubkey flows through issuance: register it, sign, and the leaf carries it.
const ca = await makeFrpClientCa()
const stores = createMemoryStores()
const hosts = createHostRegistry({ hosts: stores.hosts })
const accountId = randomUUID()
const host = await hosts.bindHost({
accountId,
subdomain: 'alice',
agentPubkey: id.publicKey,
enrollFpr: fingerprint(id.publicKey),
})
const { cert } = await makeSigner(ca, stores).signHostLeaf(host.hostId, id.publicKey, agentCsrDer)
const leaf = new x509.X509Certificate(cert)
expect(Buffer.from(leaf.publicKey.rawData).equals(Buffer.from(id.publicKey))).toBe(true)
const caPub = await importP256Public(ca.caSigner.publicKeyRaw)
expect(await leaf.verify({ publicKey: caPub, signatureOnly: true })).toBe(true)
})
})

View File

@@ -0,0 +1,238 @@
/**
* 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<KeyPair> {
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<string> {
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 { "~^(?<s>[^:]+):(?P=s)\\.terminal\\.yaojia\\.wang$" 1; default 0; }
* PCRE `(?P=s)` is the named backreference; JS spells the same backreference `\k<s>`. 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 `<label>.`) means the map does
* not rely on the server_name regex / stream SNI routing to constrain the zone. An empty $cert_sub
* (fail-closed extractor output) can never satisfy `[^:]+` → always denies.
*/
const CERT_HOST_RE = /^(?<s>[^:]+):\k<s>\.terminal\.yaojia\.wang$/
function certHostOk(certSub: string, serverName: string): 0 | 1 {
return CERT_HOST_RE.test(`${certSub}:${serverName}`) ? 1 : 0
}
describe('certHostOk — map "$cert_sub:$ssl_server_name" allow/deny (mirrors nginx PCRE)', () => {
test("own host ALLOWS: ('alice','alice.terminal.yaojia.wang') → 1", () => {
expect(certHostOk('alice', 'alice.terminal.yaojia.wang')).toBe(1)
})
test("cross-tenant DENIES: ('alice','bob.terminal.yaojia.wang') → 0 [THE NEGATIVE]", () => {
expect(certHostOk('alice', 'bob.terminal.yaojia.wang')).toBe(0)
})
test("empty cert_sub DENIES: ('','alice.terminal.yaojia.wang') → 0", () => {
expect(certHostOk('', 'alice.terminal.yaojia.wang')).toBe(0)
})
test("prefix attack DENIES: ('alice','alicex.terminal.yaojia.wang') → 0", () => {
expect(certHostOk('alice', 'alicex.terminal.yaojia.wang')).toBe(0)
})
test("no-dot server_name DENIES: ('alice','alice') → 0", () => {
expect(certHostOk('alice', 'alice')).toBe(0)
})
test("empty server_name DENIES: ('alice','') → 0", () => {
expect(certHostOk('alice', '')).toBe(0)
})
test("a second tenant ALLOWS its own host: ('bob','bob.terminal.yaojia.wang') → 1", () => {
expect(certHostOk('bob', 'bob.terminal.yaojia.wang')).toBe(1)
})
test("cross-ZONE bypass DENIES: ('alice','alice.evil.com') → 0 [zone-anchor hardening]", () => {
// A label-only map (`(?P=s)\.`) would wrongly ALLOW this; the full-zone anchor denies it, so the
// map stays sound even if the server_name regex / stream SNI routing were ever loosened.
expect(certHostOk('alice', 'alice.evil.com')).toBe(0)
expect(certHostOk('alice', 'alice.terminal.yaojia.wang.evil.com')).toBe(0)
})
test('end-to-end: extractor output feeds the map (alice cert on bob host → deny)', async () => {
const pem = await mintLeafPem([{ type: 'dns', value: 'alice.terminal.yaojia.wang' }])
const sub = extractLeftmostDnsLabel(pem)
expect(sub).toBe('alice')
expect(certHostOk(sub, 'alice.terminal.yaojia.wang')).toBe(1)
expect(certHostOk(sub, 'bob.terminal.yaojia.wang')).toBe(0)
})
})

View File

@@ -0,0 +1,372 @@
/**
* Native-tunnel wiring E2E (fastify inject against the WIRED control-plane app from `main.ts`).
*
* Proves the full DEVICE path through the real composition root:
* mint a device:enroll token (auth/session, verified by the REAL §4.3 verifier the boot wires) →
* POST /device/enroll {csr:<real P-256 CSR>, subdomain:<owned>} → 201 with a cert that RE-PARSES,
* CHAINS to the wired dev device-CA, and carries dNSName <sub>.terminal.yaojia.wang → present that
* cert to POST /device/:id/renew → 201 fresh cert for the SAME subdomain.
* Plus the A4 ownership gate end-to-end: enroll for an UNOWNED (and a foreign-owned) subdomain → 403.
*
* Also proves the HOST /renew path through the wired app: the initial frp-client leaf is minted via
* the WIRED host signer (native host HTTP /enroll is a documented follow-up — the existing pairing
* `/enroll` relay route is left untouched), then presented to /renew → 201 for the SAME subdomain.
*
* The verifier is the SAME seam the admin API uses (`boot/verifier.ts`), keyed off the boot env's
* capability pubkey — so the enroll token is minted with the matching private key and travels the
* exact verify path production uses.
*/
import 'reflect-metadata'
import { describe, test, expect, beforeEach } from 'vitest'
import * as x509 from '@peculiar/x509'
import { webcrypto } from 'node:crypto'
import type { FastifyInstance } from 'fastify'
import { generateEd25519KeyPair, exportEd25519PublicRaw } from 'relay-auth/src/crypto/ed25519.js'
import { createMemoryStores } from '../src/store/memory.js'
import { createHostRegistry } from '../src/registry/hosts.js'
import { createPairingIssuer } from '../src/pairing/issue.js'
import { fingerprint } from '../src/ca/fingerprint.js'
import { generateEd25519 } from '../src/util/crypto.js'
import { buildCsr } from '../src/ca/csr.js'
import { buildCsrEc } from '../src/ca/csr-ec.js'
import { buildControlPlane, type BuiltControlPlane } from '../src/main.js'
import { createCapabilityVerifier } from '../src/boot/verifier.js'
import { mintDeviceEnrollToken } from '../src/auth/session.js'
import { loadEnv, type ControlPlaneEnv } from '../src/env.js'
import { bytesToBase64 } from '../src/util/bytes.js'
import type { Stores } from '../src/store/ports.js'
x509.cryptoProvider.set(webcrypto)
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
const ACCOUNT_B = '22222222-2222-4222-8222-222222222222'
const DNS_ZONE = 'terminal.yaojia.wang'
let signingKey: CryptoKey
let env: ControlPlaneEnv
beforeEach(async () => {
// Ed25519 capability keypair: the raw public key becomes the boot env's verify key, so the
// device:enroll token minted with the private key verifies through the wired real §4.3 verifier.
const pair = await generateEd25519KeyPair()
signingKey = pair.privateKey
const rawPub = await exportEd25519PublicRaw(pair.publicKey)
env = loadEnv({
PG_URL: 'postgres://u:p@localhost:5432/cp',
REDIS_URL: 'redis://localhost:6379',
CAPABILITY_SIGN_PUBKEY_B64: bytesToBase64(rawPub),
CA_INTERMEDIATE_KMS_KEY_REF: 'kms://key/intermediate',
CA_INTERMEDIATE_CERT_PATH: '/etc/cp/int.pem',
NODE_MTLS_TRUST_BUNDLE_PATH: '/etc/cp/node-ca.pem',
RELAY_TRUST_DOMAIN: DNS_ZONE,
BASE_DOMAIN: 'term.example.com',
})
})
async function buildApp(): Promise<{ app: FastifyInstance; stores: Stores; built: BuiltControlPlane }> {
const stores = createMemoryStores()
const built = await buildControlPlane(env, { stores, verifier: createCapabilityVerifier() })
await built.app.ready()
return { app: built.app, stores, built }
}
/** Onboard a host so `accountId` OWNS `subdomain` in the ownership source of truth (registry). */
async function ownSubdomain(stores: Stores, accountId: string, subdomain: string): Promise<void> {
const hosts = createHostRegistry({ hosts: stores.hosts })
const { publicKeyRaw } = generateEd25519()
await hosts.bindHost({ accountId, subdomain, agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
}
/** Mint a single-use pairing code bound to `accountId` (the enroll gate reads accountId from the row). */
async function mintPairingCode(stores: Stores, accountId: string): Promise<string> {
const issuer = createPairingIssuer({ pairing: stores.pairing, pairingTtlSec: 3600 })
const { code } = await issuer.issuePairingCode(accountId)
return code
}
/** A fresh P-256 host identity: its real PKCS#10 CSR + the SPKI DER the agent presents as agentPubkey. */
async function ecHostIdentity(subject = 'CN=web-terminal-host'): Promise<{ csr: Uint8Array; spki: Uint8Array }> {
const { der: csr, keys } = await buildCsrEc(subject)
const spki = new Uint8Array(await webcrypto.subtle.exportKey('spki', keys.publicKey))
return { csr, spki }
}
/** Bind a host whose frp-client identity is a P-256 key (so the wired host signer can issue for it). */
async function bindP256Host(
stores: Stores,
accountId: string,
subdomain: string,
): Promise<{ hostId: string; csr: Uint8Array; spki: Uint8Array }> {
const hosts = createHostRegistry({ hosts: stores.hosts })
const { der: csr, keys } = await buildCsrEc(`CN=${subdomain}`)
const spki = new Uint8Array(await webcrypto.subtle.exportKey('spki', keys.publicKey))
const host = await hosts.bindHost({ accountId, subdomain, agentPubkey: spki, enrollFpr: fingerprint(spki) })
return { hostId: host.hostId, csr, spki }
}
function b64(bytes: Uint8Array): string {
return Buffer.from(bytes).toString('base64')
}
function sanNames(der: Uint8Array): ReturnType<x509.GeneralNames['toJSON']> {
const leaf = new x509.X509Certificate(der)
return leaf.getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON()
}
describe('native-tunnel wiring — DEVICE e2e (enroll → renew)', () => {
test('enroll an owned subdomain → 201 real leaf chaining to the dev device-CA → renew → 201 fresh cert, same subdomain', async () => {
const { app, stores, built } = await buildApp()
await ownSubdomain(stores, ACCOUNT_A, 'alice')
const token = await mintDeviceEnrollToken(ACCOUNT_A, { signingKey })
const { der: csr } = await buildCsrEc('CN=web-terminal-device')
// ── ENROLL ───────────────────────────────────────────────────────────────────────────────────
const enroll = await app.inject({
method: 'POST',
url: '/device/enroll',
headers: { authorization: `Bearer ${token}` },
payload: { csr: b64(csr), keyAlg: 'ec-p256', subdomain: 'alice', deviceName: 'iphone' },
})
expect(enroll.statusCode).toBe(201)
const enrolled = JSON.parse(enroll.body)
expect(typeof enrolled.deviceId).toBe('string')
expect(enrolled.deviceId.length).toBeGreaterThan(0)
// the returned cert re-parses as a real X.509 leaf …
const leafDer = new Uint8Array(Buffer.from(enrolled.cert, 'base64'))
const leaf = new x509.X509Certificate(leafDer)
// … chains to the CA the app returned …
const caDer = new Uint8Array(Buffer.from(enrolled.caChain[0], 'base64'))
const caCert = new x509.X509Certificate(caDer)
expect(await leaf.verify({ publicKey: caCert.publicKey, signatureOnly: true })).toBe(true)
// … which IS the wired dev device-CA anchor …
expect(b64(caDer)).toBe(b64(built.nativeTunnel.nativeCas.deviceCa.caCertDer))
// … and carries the tenant-isolation dNSName SAN.
expect(sanNames(leafDer)).toContainEqual({ type: 'dns', value: `alice.${DNS_ZONE}` })
// ── RENEW (present the enroll-issued cert as the current mTLS client cert) ──────────────────────
const renew = await app.inject({
method: 'POST',
url: `/device/${enrolled.deviceId}/renew`,
headers: { 'x-client-cert': b64(leafDer) },
payload: { csr: b64(csr) }, // same-key CSR
})
expect(renew.statusCode).toBe(201)
const renewed = JSON.parse(renew.body)
const renewedDer = new Uint8Array(Buffer.from(renewed.cert, 'base64'))
expect(() => new x509.X509Certificate(renewedDer)).not.toThrow()
// same subdomain — the renew stamps the registry scope, never client input.
expect(sanNames(renewedDer)).toContainEqual({ type: 'dns', value: `alice.${DNS_ZONE}` })
// and the fresh cert still chains to the device-CA anchor.
expect(await new x509.X509Certificate(renewedDer).verify({ publicKey: caCert.publicKey, signatureOnly: true })).toBe(true)
})
test('enroll for an UNOWNED subdomain → 403, no cert issued (A4 ownership gate, end-to-end)', async () => {
const { app, stores } = await buildApp()
await ownSubdomain(stores, ACCOUNT_A, 'alice')
const token = await mintDeviceEnrollToken(ACCOUNT_A, { signingKey })
const { der: csr } = await buildCsrEc('CN=web-terminal-device')
const res = await app.inject({
method: 'POST',
url: '/device/enroll',
headers: { authorization: `Bearer ${token}` },
payload: { csr: b64(csr), keyAlg: 'ec-p256', subdomain: 'charlie', deviceName: 'x' },
})
expect(res.statusCode).toBe(403)
expect(JSON.parse(res.body).cert).toBeUndefined()
})
test('account B enrolling account A\'s subdomain → 403 (cross-tenant, no cert)', async () => {
const { app, stores } = await buildApp()
await ownSubdomain(stores, ACCOUNT_A, 'alice')
const tokenB = await mintDeviceEnrollToken(ACCOUNT_B, { signingKey })
const { der: csr } = await buildCsrEc('CN=web-terminal-device')
const res = await app.inject({
method: 'POST',
url: '/device/enroll',
headers: { authorization: `Bearer ${tokenB}` },
payload: { csr: b64(csr), keyAlg: 'ec-p256', subdomain: 'alice', deviceName: 'x' },
})
expect(res.statusCode).toBe(403)
expect(JSON.parse(res.body).cert).toBeUndefined()
})
test('missing enroll token → 401 (deny-by-default through the wired verifier seam)', async () => {
const { app, stores } = await buildApp()
await ownSubdomain(stores, ACCOUNT_A, 'alice')
const { der: csr } = await buildCsrEc('CN=web-terminal-device')
const res = await app.inject({
method: 'POST',
url: '/device/enroll',
payload: { csr: b64(csr), keyAlg: 'ec-p256', subdomain: 'alice', deviceName: 'x' },
})
expect(res.statusCode).toBe(401)
})
})
describe('native-tunnel wiring — HOST /renew e2e', () => {
test('initial frp-client leaf (wired host signer) → /renew → 201 fresh cert, SAME subdomain, chains to frp-client-CA', async () => {
const { app, stores, built } = await buildApp()
const { hostId, csr, spki } = await bindP256Host(stores, ACCOUNT_A, 'alice')
// Initial leaf, minted by the WIRED host signer (chains to the wired frp-client-CA anchor).
const { cert: currentCert } = await built.nativeTunnel.hostSigner.signHostLeaf(hostId, spki, csr)
const res = await app.inject({
method: 'POST',
url: '/renew',
headers: { 'x-client-cert': b64(currentCert) },
payload: { csr: b64(csr) },
})
expect(res.statusCode).toBe(201)
const renewedDer = new Uint8Array(Buffer.from(JSON.parse(res.body).cert, 'base64'))
expect(sanNames(renewedDer)).toContainEqual({ type: 'dns', value: `alice.${DNS_ZONE}` })
const caCert = new x509.X509Certificate(built.nativeTunnel.nativeCas.frpClientCa.caCertDer)
expect(await new x509.X509Certificate(renewedDer).verify({ publicKey: caCert.publicKey, signatureOnly: true })).toBe(true)
})
test('a current cert from an UNTRUSTED (different-instance) frp-client-CA is rejected by the wired anchor set → 401', async () => {
const { app } = await buildApp()
// A second, independent wired control-plane = a DIFFERENT (untrusted) frp-client-CA. Bind the same
// host+identity there and mint a leaf under ITS CA, then present it to the REAL app's /renew.
const rogueStores = createMemoryStores()
const rogue = await buildControlPlane(env, { stores: rogueStores, verifier: createCapabilityVerifier() })
const rogueBound = await bindP256Host(rogueStores, ACCOUNT_A, 'alice')
const { cert: rogueCert } = await rogue.nativeTunnel.hostSigner.signHostLeaf(
rogueBound.hostId,
rogueBound.spki,
rogueBound.csr,
)
const res = await app.inject({
method: 'POST',
url: '/renew',
headers: { 'x-client-cert': b64(rogueCert) },
payload: { csr: b64(rogueBound.csr) },
})
expect(res.statusCode).toBe(401)
})
})
describe('native-tunnel wiring — HOST /enroll HTTP arm (P-256 frp-client leaf)', () => {
test('full host onboard: mint code → POST /enroll (EC CSR) → 201 frp-client leaf (chains to frp-client-CA + dNSName SAN, hostContentSecret null) → /renew → 201 SAME subdomain', async () => {
const { app, stores, built } = await buildApp()
const code = await mintPairingCode(stores, ACCOUNT_A)
const { csr, spki } = await ecHostIdentity()
// ── ENROLL (native P-256 arm — routed by CSR key algorithm) ────────────────────────────────────
const enroll = await app.inject({
method: 'POST',
url: '/enroll',
payload: { code, machineId: 'MB-A1B2C3', agentPubkey: b64(spki), csr: b64(csr) },
})
expect(enroll.statusCode).toBe(201)
const body = JSON.parse(enroll.body)
expect(typeof body.hostId).toBe('string')
expect(body.hostId.length).toBeGreaterThan(0)
expect(typeof body.subdomain).toBe('string')
expect(body.subdomain.length).toBeGreaterThan(0)
// native tunnel has no E2E-relay content secret (L-host-hcs).
expect(body.hostContentSecret).toBeNull()
// the returned cert re-parses as a real X.509 leaf …
const leafDer = new Uint8Array(Buffer.from(body.cert, 'base64'))
const leaf = new x509.X509Certificate(leafDer)
// … chains to the wired frp-client-CA anchor …
const caCert = new x509.X509Certificate(built.nativeTunnel.nativeCas.frpClientCa.caCertDer)
expect(await leaf.verify({ publicKey: caCert.publicKey, signatureOnly: true })).toBe(true)
const caDer = new Uint8Array(Buffer.from(body.caChain[0], 'base64'))
expect(b64(caDer)).toBe(b64(built.nativeTunnel.nativeCas.frpClientCa.caCertDer))
// … and carries the AUTHORITATIVE (server-assigned) dNSName SAN <sub>.<zone>.
expect(sanNames(leafDer)).toContainEqual({ type: 'dns', value: `${body.subdomain}.${DNS_ZONE}` })
// the host binding persisted the SAME authoritative subdomain (not client input).
const host = await stores.hosts.get(body.hostId)
expect(host?.subdomain).toBe(body.subdomain)
expect(host?.accountId).toBe(ACCOUNT_A)
// ── RENEW (present the enroll-issued cert as the current mTLS client cert) ──────────────────────
const renew = await app.inject({
method: 'POST',
url: '/renew',
headers: { 'x-client-cert': b64(leafDer) },
payload: { csr: b64(csr) }, // same-key CSR
})
expect(renew.statusCode).toBe(201)
const renewedDer = new Uint8Array(Buffer.from(JSON.parse(renew.body).cert, 'base64'))
// the renew stamps the registry scope — SAME subdomain, never client input.
expect(sanNames(renewedDer)).toContainEqual({ type: 'dns', value: `${body.subdomain}.${DNS_ZONE}` })
expect(await new x509.X509Certificate(renewedDer).verify({ publicKey: caCert.publicKey, signatureOnly: true })).toBe(true)
})
test('anti-smuggling: a client body requesting a DIFFERENT subdomain does NOT change the issued SAN', async () => {
const { app, stores } = await buildApp()
const code = await mintPairingCode(stores, ACCOUNT_A)
const { csr, spki } = await ecHostIdentity()
const enroll = await app.inject({
method: 'POST',
url: '/enroll',
// the attacker tries to steer the SAN to a subdomain it was never assigned:
payload: { code, machineId: 'x', agentPubkey: b64(spki), csr: b64(csr), subdomain: 'victim' },
})
expect(enroll.statusCode).toBe(201)
const body = JSON.parse(enroll.body)
// the SAN is the SERVER-assigned subdomain, never the client-supplied 'victim'.
expect(body.subdomain).not.toBe('victim')
const leafDer = new Uint8Array(Buffer.from(body.cert, 'base64'))
expect(sanNames(leafDer)).toContainEqual({ type: 'dns', value: `${body.subdomain}.${DNS_ZONE}` })
expect(sanNames(leafDer)).not.toContainEqual({ type: 'dns', value: `victim.${DNS_ZONE}` })
// and the host registry bound the server-assigned subdomain, not 'victim'.
const host = await stores.hosts.get(body.hostId)
expect(host?.subdomain).toBe(body.subdomain)
expect(host?.subdomain).not.toBe('victim')
})
test('single-use: replaying an already-redeemed pairing code → NOT 201 (double-spend guard)', async () => {
const { app, stores } = await buildApp()
const code = await mintPairingCode(stores, ACCOUNT_A)
const first = await ecHostIdentity('CN=host-1')
const ok = await app.inject({
method: 'POST',
url: '/enroll',
payload: { code, machineId: 'a', agentPubkey: b64(first.spki), csr: b64(first.csr) },
})
expect(ok.statusCode).toBe(201)
const second = await ecHostIdentity('CN=host-2')
const replay = await app.inject({
method: 'POST',
url: '/enroll',
payload: { code, machineId: 'a', agentPubkey: b64(second.spki), csr: b64(second.csr) },
})
expect(replay.statusCode).not.toBe(201)
})
test('no-substitution: agentPubkey that does not match the CSR key → NOT 201', async () => {
const { app, stores } = await buildApp()
const code = await mintPairingCode(stores, ACCOUNT_A)
const { csr } = await ecHostIdentity('CN=host-real')
const other = await ecHostIdentity('CN=host-other')
const res = await app.inject({
method: 'POST',
url: '/enroll',
// present a DIFFERENT key than the CSR embeds — the gate's no-substitution check must reject.
payload: { code, machineId: 'a', agentPubkey: b64(other.spki), csr: b64(csr) },
})
expect(res.statusCode).not.toBe(201)
})
test('non-regression: an Ed25519 relay /enroll still issues the PEM relay leaf + wrapped secret (native arm does not hijack it)', async () => {
const { app, stores } = await buildApp()
const code = await mintPairingCode(stores, ACCOUNT_A)
const { publicKeyRaw, privateKey } = generateEd25519()
const res = await app.inject({
method: 'POST',
url: '/enroll',
payload: { code, agentPubkey: bytesToBase64(publicKeyRaw), csr: bytesToBase64(buildCsr(privateKey, publicKeyRaw)) },
})
expect(res.statusCode).toBe(201)
const body = JSON.parse(res.body)
expect(body.cert).toContain('BEGIN CERTIFICATE') // PEM relay leaf, unchanged
expect(typeof body.hostContentSecret).toBe('string') // base64url wrapped secret, NOT null
})
})

View File

@@ -0,0 +1,95 @@
/**
* A4 — device registry (mirrors registry/hosts.ts). Proves: create/get; versioned status swap
* (INV8 append-only companion rows + atomic pointer swap); per-account device CAP enforcement;
* per-account enroll RATE-LIMIT; ownership deny-by-default.
*/
import { describe, test, expect } from 'vitest'
import {
createDeviceRegistry,
createMemoryDeviceStore,
DeviceLimitError,
type RegisterDeviceInput,
} from '../../src/registry/devices.js'
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
const ACCOUNT_B = '22222222-2222-4222-8222-222222222222'
function input(overrides: Partial<RegisterDeviceInput> = {}): RegisterDeviceInput {
return {
accountId: ACCOUNT_A,
subdomainScope: 'alice',
ecPubkeySpki: new Uint8Array([1, 2, 3, 4]),
attestationLevel: 'none',
...overrides,
}
}
describe('A4 device registry', () => {
test('registerDevice → getDevice returns the persisted record with a serial + notAfter', async () => {
const devices = createDeviceRegistry({ devices: createMemoryDeviceStore() })
const rec = await devices.registerDevice(input())
expect(rec.deviceId.length).toBeGreaterThan(0)
expect(rec.accountId).toBe(ACCOUNT_A)
expect(rec.subdomainScope).toBe('alice')
expect(rec.status).toBe('active')
expect(rec.serial.length).toBeGreaterThan(0)
expect(Date.parse(rec.notAfter)).toBeGreaterThan(Date.now())
expect(await devices.getDevice(rec.deviceId)).toEqual(rec)
})
test('ecPubkeySpki is defensively copied (public key only, immutable)', async () => {
const devices = createDeviceRegistry({ devices: createMemoryDeviceStore() })
const src = new Uint8Array([9, 9, 9])
const rec = await devices.registerDevice(input({ ecPubkeySpki: src }))
src[0] = 0 // mutate the caller's buffer
expect(Array.from(rec.ecPubkeySpki)).toEqual([9, 9, 9])
})
test('setDeviceStatus revokes with a versioned snapshot (INV8)', async () => {
const store = createMemoryDeviceStore()
const devices = createDeviceRegistry({ devices: store })
const rec = await devices.registerDevice(input())
const revoked = await devices.setDeviceStatus(rec.deviceId, 'revoked')
expect(revoked.status).toBe('revoked')
expect(revoked.revokedAt).not.toBeNull()
const versions = await store.versions(rec.deviceId)
expect(versions.length).toBe(2) // insert + revoke
expect(versions[1]?.status).toBe('revoked')
})
test('ownsDevice is deny-by-default (unknown device / foreign account ⇒ false)', async () => {
const devices = createDeviceRegistry({ devices: createMemoryDeviceStore() })
const rec = await devices.registerDevice(input())
expect(await devices.ownsDevice(ACCOUNT_A, rec.deviceId)).toBe(true)
expect(await devices.ownsDevice(ACCOUNT_B, rec.deviceId)).toBe(false)
expect(await devices.ownsDevice(ACCOUNT_A, 'nope')).toBe(false)
})
test('per-account device CAP is enforced (active count only)', async () => {
const devices = createDeviceRegistry({ devices: createMemoryDeviceStore(), deviceCap: 2 })
await devices.assertUnderCap(ACCOUNT_A) // 0 < 2
const r1 = await devices.registerDevice(input())
await devices.registerDevice(input())
await expect(devices.assertUnderCap(ACCOUNT_A)).rejects.toBeInstanceOf(DeviceLimitError)
// a different account is unaffected
await devices.assertUnderCap(ACCOUNT_B)
// revoking one frees a slot
await devices.setDeviceStatus(r1.deviceId, 'revoked')
await devices.assertUnderCap(ACCOUNT_A)
})
test('per-account enroll RATE-LIMIT throws after the window budget', () => {
const devices = createDeviceRegistry({ devices: createMemoryDeviceStore(), rateMax: 2 })
devices.checkRateLimit(ACCOUNT_A)
devices.checkRateLimit(ACCOUNT_A)
expect(() => devices.checkRateLimit(ACCOUNT_A)).toThrow(DeviceLimitError)
// a different account has its own budget
expect(() => devices.checkRateLimit(ACCOUNT_B)).not.toThrow()
})
test('DeviceLimitError carries a machine code (cap vs rate) but a uniform message', async () => {
const devices = createDeviceRegistry({ devices: createMemoryDeviceStore(), deviceCap: 0 })
await devices.registerDevice(input()).catch(() => undefined)
await expect(devices.assertUnderCap(ACCOUNT_A)).rejects.toMatchObject({ code: 'cap_exceeded' })
})
})

View File

@@ -0,0 +1,523 @@
/**
* A6 (FIX H-host-3) — POST /renew (host) + POST /device/:id/renew (device) route tests (fastify
* inject). Both routes are authenticated ONLY by the CURRENT client cert the mTLS terminator forwards
* (injected here via the `x-client-cert` header); the body carries ONLY the new CSR. Proves: a valid
* current cert + a valid SAME-KEY CSR → 201 with a real X.509 leaf carrying the SAME subdomain (no
* smuggling); a missing / malformed current cert → 401; a revoked / unknown / mismatched identity →
* 403; a DIFFERENT-key CSR → 400 (MVP same-key); over the per-identity rate → 429.
*/
import 'reflect-metadata'
import { describe, test, expect } from 'vitest'
import Fastify, { type FastifyInstance } from 'fastify'
import * as x509 from '@peculiar/x509'
import { webcrypto, generateKeyPairSync, randomUUID } from 'node:crypto'
import { createMemoryStores } from '../src/store/memory.js'
import { createHostRegistry, type HostRegistry } from '../src/registry/hosts.js'
import { createDeviceRegistry, createMemoryDeviceStore, type DeviceRegistry } from '../src/registry/devices.js'
import { fingerprint } from '../src/ca/fingerprint.js'
import { assembleCertificate } from '../src/ca/x509-assembler.js'
import { buildCsrEc } from '../src/ca/csr-ec.js'
import { createFrpClientLeafSigner } from '../src/ca/frpclient-issue.js'
import { createDeviceLeafSigner, type DeviceLeafSigner } from '../src/ca/device-issue.js'
import { createLeafRenewer } from '../src/ca/rotate.js'
import type { LeafSigner } from '../src/ca/sign.js'
import { inProcessP256CaSigner, type CaSigner } from '../src/boot/ca-wiring.js'
import { buildRenewRouter, createRenewRateLimiter, type RenewDeps } from '../src/api/renew.js'
x509.cryptoProvider.set(webcrypto)
const DAY_MS = 24 * 60 * 60 * 1000
const TRUST_DOMAIN = 'terminal.yaojia.wang'
const ZONE = 'terminal.yaojia.wang'
interface P256Ca {
readonly caSigner: CaSigner
readonly caCert: x509.X509Certificate
readonly caDer: Uint8Array
}
async function makeP256Ca(cn: string): Promise<P256Ca> {
const caKeys = generateKeyPairSync('ec', { namedCurve: 'P-256' })
const caSpki = new Uint8Array(caKeys.publicKey.export({ format: 'der', type: 'spki' }))
const caSigner = inProcessP256CaSigner(caKeys.privateKey)
const now = Date.now()
const caDer = await assembleCertificate({
subjectPublicKey: caSpki,
subject: `CN=${cn}`,
issuer: `CN=${cn}`,
serialNumber: Uint8Array.from([0x01]),
notBefore: new Date(now - DAY_MS),
notAfter: new Date(now + 365 * DAY_MS),
extensions: [
new x509.BasicConstraintsExtension(true, undefined, true),
new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign, true),
],
signer: caSigner,
sigAlg: 'ecdsa-p256',
})
return { caSigner, caCert: new x509.X509Certificate(caDer), caDer }
}
async function spkiOf(pub: CryptoKey): Promise<Uint8Array> {
return new Uint8Array(await webcrypto.subtle.exportKey('spki', pub))
}
/** base64-DER cert value for the `x-client-cert` header (what the mTLS terminator forwards). */
function certHeader(der: Uint8Array): string {
return Buffer.from(der).toString('base64')
}
function b64Csr(der: Uint8Array): string {
return Buffer.from(der).toString('base64')
}
function appWith(deps: RenewDeps): FastifyInstance {
const app = Fastify({ logger: false })
void app.register(buildRenewRouter(deps))
return app
}
// ── HOST /renew ──────────────────────────────────────────────────────────────────────────────────
interface HostCtx {
hosts: HostRegistry
renewer: ReturnType<typeof createLeafRenewer>
hostSigner: LeafSigner
ca: P256Ca
subdomain: string
spki: Uint8Array
keys: { readonly publicKey: CryptoKey; readonly privateKey: CryptoKey }
csr: Uint8Array
currentCertDer: Uint8Array
accountId: string
deviceSigner: DeviceLeafSigner
}
async function hostCtx(subdomain = 'alice', hosts?: HostRegistry): Promise<HostCtx> {
const ca = await makeP256Ca('frp-client-CA')
const stores = createMemoryStores()
const reg = hosts ?? createHostRegistry({ hosts: stores.hosts })
const { der: csr, keys } = await buildCsrEc(`CN=${subdomain}`)
const spki = await spkiOf(keys.publicKey)
const accountId = randomUUID()
const host = await reg.bindHost({ accountId, subdomain, agentPubkey: spki, enrollFpr: fingerprint(spki) })
const hostSigner = createFrpClientLeafSigner({
hosts: stores.hosts,
signer: ca.caSigner,
issuerName: ca.caCert.subjectName,
caChainDer: [ca.caDer],
trustDomain: TRUST_DOMAIN,
dnsZone: ZONE,
})
const throwawayDeviceStore = createMemoryDeviceStore()
const deviceSigner = createDeviceLeafSigner({
signer: (await makeP256Ca('device-CA')).caSigner,
issuer: 'CN=device-CA',
caChainDer: [],
sanBaseDomain: ZONE,
trustDomain: TRUST_DOMAIN,
devices: throwawayDeviceStore,
})
const renewer = createLeafRenewer({
hostSigner,
deviceSigner,
deviceExpiry: createDeviceRegistry({ devices: throwawayDeviceStore }),
})
// The CURRENT cert = an initial leaf issued for this host (what the mTLS layer would present).
const { cert: currentCertDer } = await hostSigner.signHostLeaf(host.hostId, spki, csr)
return { hosts: reg, renewer, hostSigner, ca, subdomain, spki, keys, csr, currentCertDer, accountId, deviceSigner }
}
function hostDeps(ctx: HostCtx, extra?: Partial<RenewDeps>): RenewDeps {
return {
hosts: ctx.hosts,
devices: createDeviceRegistry({ devices: createMemoryDeviceStore() }),
renewer: ctx.renewer,
...extra,
}
}
describe('A6 POST /renew (host, mTLS current cert)', () => {
test('valid current cert + same-key CSR → 201 real X.509 leaf carrying the SAME subdomain', async () => {
const ctx = await hostCtx('alice')
const app = appWith(hostDeps(ctx))
await app.ready()
const res = await app.inject({
method: 'POST',
url: '/renew',
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
payload: { csr: b64Csr(ctx.csr) },
})
expect(res.statusCode).toBe(201)
const body = JSON.parse(res.body)
expect(typeof body.notAfter).toBe('string')
expect(Array.isArray(body.caChain)).toBe(true)
const leaf = new x509.X509Certificate(new Uint8Array(Buffer.from(body.cert, 'base64')))
const names = leaf.getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON()
expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' })
})
test('missing current cert → 401', async () => {
const ctx = await hostCtx('alice')
const app = appWith(hostDeps(ctx))
await app.ready()
const res = await app.inject({ method: 'POST', url: '/renew', payload: { csr: b64Csr(ctx.csr) } })
expect(res.statusCode).toBe(401)
})
test('malformed current cert bytes → 401', async () => {
const ctx = await hostCtx('alice')
const app = appWith(hostDeps(ctx))
await app.ready()
const res = await app.inject({
method: 'POST',
url: '/renew',
headers: { 'x-client-cert': certHeader(Uint8Array.from([0, 1, 2, 3])) },
payload: { csr: b64Csr(ctx.csr) },
})
expect(res.statusCode).toBe(401)
})
test('CSR with a DIFFERENT key (key-swap attempt) → 400 (MVP same-key)', async () => {
const ctx = await hostCtx('alice')
const app = appWith(hostDeps(ctx))
await app.ready()
const { der: otherCsr } = await buildCsrEc('CN=alice')
const res = await app.inject({
method: 'POST',
url: '/renew',
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
payload: { csr: b64Csr(otherCsr) },
})
expect(res.statusCode).toBe(400)
})
test('revoked host → 403', async () => {
const ctx = await hostCtx('alice')
const host = await ctx.hosts.getHostBySubdomain('alice')
await ctx.hosts.setHostStatus(host!.hostId, 'revoked')
const app = appWith(hostDeps(ctx))
await app.ready()
const res = await app.inject({
method: 'POST',
url: '/renew',
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
payload: { csr: b64Csr(ctx.csr) },
})
expect(res.statusCode).toBe(403)
})
test('current cert whose subdomain is unknown to the registry → 403', async () => {
const ctx = await hostCtx('alice')
// Route uses a FRESH empty registry — the presented cert's subdomain resolves to nothing.
const emptyStores = createMemoryStores()
const app = appWith({
hosts: createHostRegistry({ hosts: emptyStores.hosts }),
devices: createDeviceRegistry({ devices: createMemoryDeviceStore() }),
renewer: ctx.renewer,
})
await app.ready()
const res = await app.inject({
method: 'POST',
url: '/renew',
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
payload: { csr: b64Csr(ctx.csr) },
})
expect(res.statusCode).toBe(403)
})
test('a renewal CSR that requests a foreign subject is stamped with the REGISTRY subdomain (no smuggling)', async () => {
const ctx = await hostCtx('alice')
const app = appWith(hostDeps(ctx))
await app.ready()
// Same key, but a malicious subject label — the issuer must ignore it and stamp 'alice'.
const evilCsr = new Uint8Array(
(
await x509.Pkcs10CertificateRequestGenerator.create({
name: 'CN=bob.terminal.yaojia.wang',
keys: ctx.keys,
signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' },
})
).rawData,
)
const res = await app.inject({
method: 'POST',
url: '/renew',
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
payload: { csr: b64Csr(evilCsr) },
})
expect(res.statusCode).toBe(201)
const leaf = new x509.X509Certificate(new Uint8Array(Buffer.from(JSON.parse(res.body).cert, 'base64')))
const names = leaf.getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON()
expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' })
expect(names).not.toContainEqual({ type: 'dns', value: 'bob.terminal.yaojia.wang' })
})
test('over the per-identity renewal rate → 429', async () => {
const ctx = await hostCtx('alice')
const app = appWith(hostDeps(ctx, { rateLimiter: createRenewRateLimiter({ max: 1 }) }))
await app.ready()
const first = await app.inject({
method: 'POST',
url: '/renew',
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
payload: { csr: b64Csr(ctx.csr) },
})
expect(first.statusCode).toBe(201)
const second = await app.inject({
method: 'POST',
url: '/renew',
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
payload: { csr: b64Csr(ctx.csr) },
})
expect(second.statusCode).toBe(429)
})
})
// ── DEVICE /device/:id/renew ─────────────────────────────────────────────────────────────────────
interface DeviceCtx {
devices: DeviceRegistry
renewer: ReturnType<typeof createLeafRenewer>
deviceSigner: DeviceLeafSigner
ca: P256Ca
deviceId: string
spki: Uint8Array
csr: Uint8Array
currentCertDer: Uint8Array
}
async function registerDeviceLeaf(
registry: DeviceRegistry,
store: ReturnType<typeof createMemoryDeviceStore>,
deviceSigner: DeviceLeafSigner,
scope = 'alice',
) {
const { der: csr, keys } = await buildCsrEc('CN=web-terminal-device')
const spki = await spkiOf(keys.publicKey)
const record = await registry.registerDevice({ accountId: randomUUID(), subdomainScope: scope, ecPubkeySpki: spki, attestationLevel: 'none' })
const { cert: currentCertDer } = await deviceSigner.signDeviceLeaf(record.deviceId, csr)
return { record, csr, spki, currentCertDer }
}
async function deviceCtx(scope = 'alice'): Promise<DeviceCtx> {
const ca = await makeP256Ca('device-CA')
const store = createMemoryDeviceStore()
const registry = createDeviceRegistry({ devices: store })
const deviceSigner = createDeviceLeafSigner({
signer: ca.caSigner,
issuer: ca.caCert.subjectName,
caChainDer: [ca.caDer],
sanBaseDomain: ZONE,
trustDomain: TRUST_DOMAIN,
devices: store,
})
const hostSigner = createFrpClientLeafSigner({
hosts: createMemoryStores().hosts,
signer: ca.caSigner,
issuerName: 'CN=frp-client-CA',
caChainDer: [],
trustDomain: TRUST_DOMAIN,
dnsZone: ZONE,
})
const renewer = createLeafRenewer({ hostSigner, deviceSigner, deviceExpiry: registry })
const { record, csr, spki, currentCertDer } = await registerDeviceLeaf(registry, store, deviceSigner, scope)
return { devices: registry, renewer, deviceSigner, ca, deviceId: record.deviceId, spki, csr, currentCertDer }
}
function deviceDeps(ctx: DeviceCtx): RenewDeps {
return {
hosts: createHostRegistry({ hosts: createMemoryStores().hosts }),
devices: ctx.devices,
renewer: ctx.renewer,
}
}
describe('A6 POST /device/:id/renew (device, mTLS current cert)', () => {
test('valid current cert + same-key CSR → 201 real X.509 leaf with the SAME subdomainScope', async () => {
const ctx = await deviceCtx('alice')
const app = appWith(deviceDeps(ctx))
await app.ready()
const res = await app.inject({
method: 'POST',
url: `/device/${ctx.deviceId}/renew`,
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
payload: { csr: b64Csr(ctx.csr) },
})
expect(res.statusCode).toBe(201)
const leaf = new x509.X509Certificate(new Uint8Array(Buffer.from(JSON.parse(res.body).cert, 'base64')))
const names = leaf.getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON()
expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' })
})
test('missing current cert → 401', async () => {
const ctx = await deviceCtx('alice')
const app = appWith(deviceDeps(ctx))
await app.ready()
const res = await app.inject({ method: 'POST', url: `/device/${ctx.deviceId}/renew`, payload: { csr: b64Csr(ctx.csr) } })
expect(res.statusCode).toBe(401)
})
test('CSR with a DIFFERENT key → 400 (MVP same-key)', async () => {
const ctx = await deviceCtx('alice')
const app = appWith(deviceDeps(ctx))
await app.ready()
const { der: otherCsr } = await buildCsrEc('CN=web-terminal-device')
const res = await app.inject({
method: 'POST',
url: `/device/${ctx.deviceId}/renew`,
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
payload: { csr: b64Csr(otherCsr) },
})
expect(res.statusCode).toBe(400)
})
test("presenting device X's cert against a DIFFERENT device's :id → 403", async () => {
const ctx = await deviceCtx('alice')
// A second, distinct device in the same registry.
const store = createMemoryDeviceStore()
const registry = createDeviceRegistry({ devices: store })
const otherSigner = createDeviceLeafSigner({
signer: ctx.ca.caSigner,
issuer: ctx.ca.caCert.subjectName,
caChainDer: [ctx.ca.caDer],
sanBaseDomain: ZONE,
trustDomain: TRUST_DOMAIN,
devices: store,
})
const other = await registerDeviceLeaf(registry, store, otherSigner, 'alice')
// Route registry holds BOTH devices; present X's cert to Y's path.
const merged = createMemoryDeviceStore()
const mergedReg = createDeviceRegistry({ devices: merged })
const recX = await mergedReg.registerDevice({ accountId: randomUUID(), subdomainScope: 'alice', ecPubkeySpki: ctx.spki, attestationLevel: 'none' })
const recY = await mergedReg.registerDevice({ accountId: randomUUID(), subdomainScope: 'alice', ecPubkeySpki: other.spki, attestationLevel: 'none' })
void recX
const app = appWith({ hosts: createHostRegistry({ hosts: createMemoryStores().hosts }), devices: mergedReg, renewer: ctx.renewer })
await app.ready()
const res = await app.inject({
method: 'POST',
url: `/device/${recY.deviceId}/renew`, // Y's path
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) }, // X's cert (SPIFFE deviceId ≠ recY)
payload: { csr: b64Csr(ctx.csr) },
})
expect(res.statusCode).toBe(403)
})
test('revoked device → 403', async () => {
const ctx = await deviceCtx('alice')
await ctx.devices.setDeviceStatus(ctx.deviceId, 'revoked')
const app = appWith(deviceDeps(ctx))
await app.ready()
const res = await app.inject({
method: 'POST',
url: `/device/${ctx.deviceId}/renew`,
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
payload: { csr: b64Csr(ctx.csr) },
})
expect(res.statusCode).toBe(403)
})
})
// ── CP6a: bounded rate-limiter hits Map (memory-DoS guard) ─────────────────────────────────────────
describe('CP6a createRenewRateLimiter — bounded hits Map', () => {
test('sweeps fully-expired identities so the Map does not grow unbounded', () => {
let t = 0
const rl = createRenewRateLimiter({ max: 5, windowMs: 1000, maxIdentities: 2, now: () => t })
rl.check('a')
rl.check('b')
expect(rl.size()).toBe(2) // at the cap
t = 5000 // a & b are now well past their 1s window
rl.check('c') // inserting a new identity at the cap sweeps the two stale windows first
expect(rl.size()).toBe(1) // only 'c' survives
})
test('caps tracked identities under a distinct-identity flood (all within window)', () => {
const rl = createRenewRateLimiter({ max: 5, windowMs: 60_000, maxIdentities: 2, now: () => 0 })
for (let i = 0; i < 100; i++) rl.check(`id-${i}`)
expect(rl.size()).toBeLessThanOrEqual(2)
})
})
// ── CP6b + CP6c: body size cap + presented-cert chain/expiry verification ───────────────────────────
describe('CP6b POST /renew — CSR length cap', () => {
test('an over-long CSR is rejected by the body schema → 400', async () => {
const ctx = await hostCtx('alice')
const app = appWith(hostDeps(ctx))
await app.ready()
const res = await app.inject({
method: 'POST',
url: '/renew',
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
payload: { csr: 'A'.repeat(9000) }, // > MAX_CSR_B64_LEN (8192)
})
expect(res.statusCode).toBe(400)
})
})
describe('CP6c POST /renew — presented current-cert chain + expiry verification', () => {
/** Mint a host leaf (SPIFFE host SAN, chained to the frp-client-CA) with a caller-chosen validity. */
async function mintHostLeaf(ctx: HostCtx, notBefore: Date, notAfter: Date, signer = ctx.ca.caSigner): Promise<Uint8Array> {
const spiffe = `spiffe://relay.terminal.yaojia.wang/account/${ctx.accountId}/host/${ctx.subdomain}`
return assembleCertificate({
subjectPublicKey: ctx.spki,
subject: `CN=${ctx.subdomain}`,
issuer: ctx.ca.caCert.subjectName,
serialNumber: Uint8Array.from([0x09]),
notBefore,
notAfter,
extensions: [
new x509.SubjectAlternativeNameExtension([
{ type: 'dns', value: `${ctx.subdomain}.terminal.yaojia.wang` },
{ type: 'url', value: spiffe },
]),
new x509.BasicConstraintsExtension(false, undefined, true),
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.clientAuth]),
],
signer,
sigAlg: 'ecdsa-p256',
})
}
test('a valid current cert that CHAINS to the supplied anchor still renews → 201 (no regression)', async () => {
const ctx = await hostCtx('alice')
const app = appWith(hostDeps(ctx, { hostCaAnchorsDer: [ctx.ca.caDer] }))
await app.ready()
const res = await app.inject({
method: 'POST',
url: '/renew',
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
payload: { csr: b64Csr(ctx.csr) },
})
expect(res.statusCode).toBe(201)
})
test('an EXPIRED current cert (valid chain, notAfter in the past) is rejected → 401', async () => {
const ctx = await hostCtx('alice')
const now = Date.now()
const expired = await mintHostLeaf(ctx, new Date(now - 2 * DAY_MS), new Date(now - DAY_MS))
const app = appWith(hostDeps(ctx, { hostCaAnchorsDer: [ctx.ca.caDer] }))
await app.ready()
const res = await app.inject({
method: 'POST',
url: '/renew',
headers: { 'x-client-cert': certHeader(expired) },
payload: { csr: b64Csr(ctx.csr) },
})
expect(res.statusCode).toBe(401)
})
test('a current cert signed by an UNTRUSTED CA (not in the anchor set) is rejected → 401', async () => {
const ctx = await hostCtx('alice')
const rogueCa = await makeP256Ca('rogue-CA')
const now = Date.now()
const rogue = await mintHostLeaf(ctx, new Date(now - 60_000), new Date(now + DAY_MS), rogueCa.caSigner)
const app = appWith(hostDeps(ctx, { hostCaAnchorsDer: [ctx.ca.caDer] }))
await app.ready()
const res = await app.inject({
method: 'POST',
url: '/renew',
headers: { 'x-client-cert': certHeader(rogue) },
payload: { csr: b64Csr(ctx.csr) },
})
expect(res.statusCode).toBe(401)
})
})

View File

@@ -1,49 +1,245 @@
/**
* A6 (FIX H-host-3) — leaf RENEWAL upgraded off the DEV JSON-blob placeholder to REAL X.509. Proves
* `createLeafRenewer` re-issues a tool-parseable, CA-verifiable X.509 v3 leaf (host + device) that
* carries the SAME subdomain the registry holds (no smuggling), enforces the SAME-KEY rule (a
* different-key CSR is rejected), and refuses a revoked identity — all by DELEGATING to the P-256
* issuers (`frpclient-issue` / `device-issue`) that route through `assembleCertificate`. The final
* `buildCaSigner` block (startup KMS key-policy fail-fast, INV9) is unrelated to rotation but colocated.
*/
import 'reflect-metadata'
import { describe, test, expect } from 'vitest'
import * as x509 from '@peculiar/x509'
import { webcrypto, generateKeyPairSync, randomUUID } from 'node:crypto'
import { parseSpiffeId } from 'relay-auth/src/agent/spiffe.js'
import { createMemoryStores } from '../src/store/memory.js'
import { createHostRegistry } from '../src/registry/hosts.js'
import { createDeviceRegistry, createMemoryDeviceStore } from '../src/registry/devices.js'
import { fingerprint } from '../src/ca/fingerprint.js'
import { assembleCertificate } from '../src/ca/x509-assembler.js'
import { buildCsrEc } from '../src/ca/csr-ec.js'
import { createFrpClientLeafSigner } from '../src/ca/frpclient-issue.js'
import { createDeviceLeafSigner } from '../src/ca/device-issue.js'
import { createLeafRenewer } from '../src/ca/rotate.js'
import { LeafSignError } from '../src/ca/sign.js'
import { buildCaSigner, inProcessCaSigner, type KmsResolver } from '../src/boot/ca-wiring.js'
import { buildCsr } from '../src/ca/csr.js'
import { fingerprint } from '../src/ca/fingerprint.js'
import { generateEd25519 } from '../src/util/crypto.js'
import { DeviceLeafSignError } from '../src/ca/device-issue.js'
import {
buildCaSigner,
inProcessCaSigner,
inProcessP256CaSigner,
type CaSigner,
type KmsResolver,
} from '../src/boot/ca-wiring.js'
import { loadEnv } from '../src/env.js'
import { bytesToBase64 } from '../src/util/bytes.js'
import { randomBytes } from 'node:crypto'
const ACCOUNT = '11111111-1111-4111-8111-111111111111'
x509.cryptoProvider.set(webcrypto)
async function boundHost() {
const stores = createMemoryStores()
const hosts = createHostRegistry({ hosts: stores.hosts })
const { publicKeyRaw, privateKey } = generateEd25519()
const host = await hosts.bindHost({ accountId: ACCOUNT, subdomain: 'alice', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
const renewer = createLeafRenewer({ hosts: stores.hosts, signer: inProcessCaSigner(), caChainDer: [] })
return { stores, hosts, host, publicKeyRaw, privateKey, renewer }
const DAY_MS = 24 * 60 * 60 * 1000
const TRUST_DOMAIN = 'terminal.yaojia.wang'
const DNS_ZONE = 'terminal.yaojia.wang'
const BASE_DOMAIN = 'terminal.yaojia.wang'
interface P256Ca {
readonly caSigner: CaSigner
readonly caCert: x509.X509Certificate
readonly caDer: Uint8Array
}
describe('T15 renewHostLeaf (INV14)', () => {
test('active host + valid CSR → fresh short-TTL leaf (same subject pubkey)', async () => {
const { host, publicKeyRaw, privateKey, renewer } = await boundHost()
const { cert } = await renewer.renewHostLeaf(host.hostId, buildCsr(privateKey, publicKeyRaw))
const certJson = JSON.parse(Buffer.from(cert).toString())
const tbs = JSON.parse(Buffer.from(certJson.tbs, 'base64').toString())
expect(tbs.subjectSpki).toBe(Buffer.from(publicKeyRaw).toString('base64'))
expect(tbs.renewed).toBe(true)
/** Self-signed P-256 CA (subject key == signer key) so leaves chain to a real CA. */
async function makeP256Ca(cn: string): Promise<P256Ca> {
const caKeys = generateKeyPairSync('ec', { namedCurve: 'P-256' })
const caSpki = new Uint8Array(caKeys.publicKey.export({ format: 'der', type: 'spki' }))
const caSigner = inProcessP256CaSigner(caKeys.privateKey)
const now = Date.now()
const caDer = await assembleCertificate({
subjectPublicKey: caSpki,
subject: `CN=${cn}`,
issuer: `CN=${cn}`,
serialNumber: Uint8Array.from([0x01]),
notBefore: new Date(now - DAY_MS),
notAfter: new Date(now + 365 * DAY_MS),
extensions: [
new x509.BasicConstraintsExtension(true, undefined, true),
new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true),
],
signer: caSigner,
sigAlg: 'ecdsa-p256',
})
return { caSigner, caCert: new x509.X509Certificate(caDer), caDer }
}
async function importP256Public(spkiDer: Uint8Array): Promise<CryptoKey> {
return webcrypto.subtle.importKey('spki', new Uint8Array(spkiDer), { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'])
}
async function spkiOf(pub: CryptoKey): Promise<Uint8Array> {
return new Uint8Array(await webcrypto.subtle.exportKey('spki', pub))
}
// ── Host renewal setup ─────────────────────────────────────────────────────────────────────────────
async function hostRenewFixture(subdomain = 'alice') {
const ca = await makeP256Ca('frp-client-CA')
const stores = createMemoryStores()
const hosts = createHostRegistry({ hosts: stores.hosts })
const { der: csr, keys } = await buildCsrEc(`CN=${subdomain}`)
const spki = await spkiOf(keys.publicKey)
const accountId = randomUUID()
const host = await hosts.bindHost({ accountId, subdomain, agentPubkey: spki, enrollFpr: fingerprint(spki) })
const hostSigner = createFrpClientLeafSigner({
hosts: stores.hosts,
signer: ca.caSigner,
issuerName: ca.caCert.subjectName,
caChainDer: [ca.caDer],
trustDomain: TRUST_DOMAIN,
dnsZone: DNS_ZONE,
})
// Device signer + expiry renewer are required by the renewer shape but unused in host tests — a
// throwaway pair sharing one store.
const throwawayDeviceStore = createMemoryDeviceStore()
const deviceSigner = createDeviceLeafSigner({
signer: (await makeP256Ca('device-CA')).caSigner,
issuer: 'CN=device-CA',
caChainDer: [],
sanBaseDomain: BASE_DOMAIN,
trustDomain: TRUST_DOMAIN,
devices: throwawayDeviceStore,
})
const renewer = createLeafRenewer({
hostSigner,
deviceSigner,
deviceExpiry: createDeviceRegistry({ devices: throwawayDeviceStore }),
})
return { ca, stores, hosts, host, csr, spki, keys, accountId, renewer }
}
describe('A6 renewHostLeaf → REAL X.509 (FIX H-host-3)', () => {
test('active host + same-key CSR → a re-parseable, CA-verifiable X.509 leaf carrying the SAME subdomain', async () => {
const { ca, host, csr, accountId, renewer } = await hostRenewFixture('alice')
const { cert, caChain, notAfter } = await renewer.renewHostLeaf(host.hostId, host.agentPubkey, csr)
// (1) it is a REAL X.509 cert — not a JSON blob — that re-parses.
expect(() => new x509.X509Certificate(cert)).not.toThrow()
const leaf = new x509.X509Certificate(cert)
// (2) it chains to the frp-client-CA (signature verifies under the CA key).
const caPub = await importP256Public(ca.caSigner.publicKeyRaw)
expect(await leaf.verify({ publicKey: caPub, signatureOnly: true })).toBe(true)
// (3) SAN carries the registry subdomain as the dNSName enforcement key + the host SPIFFE URI.
const san = leaf.getExtension(x509.SubjectAlternativeNameExtension)
const names = san!.names.toJSON()
expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' })
const uri = names.find((n) => n.type === 'url')!.value
expect(parseSpiffeId(uri)).toEqual({ accountId, id: 'alice', kind: 'host' })
// (4) renewal surfaces a real future expiry + the CA chain.
expect(notAfter.getTime()).toBeGreaterThan(Date.now())
expect(caChain).toEqual([ca.caDer])
})
test('CSR proof-of-possession fails → reject even for an active host', async () => {
const { host, publicKeyRaw, renewer } = await boundHost()
const forged = new Uint8Array(96)
forged.set(publicKeyRaw, 0)
forged.set(randomBytes(64), 32) // garbage signature
await expect(renewer.renewHostLeaf(host.hostId, forged)).rejects.toBeInstanceOf(LeafSignError)
test('a CSR with a DIFFERENT key (key-swap attempt) → rejected (MVP same-key)', async () => {
const { host, renewer } = await hostRenewFixture('alice')
const { der: otherCsr } = await buildCsrEc('CN=alice') // fresh, different P-256 key
await expect(renewer.renewHostLeaf(host.hostId, host.agentPubkey, otherCsr)).rejects.toBeInstanceOf(LeafSignError)
})
test('revoked host cannot renew (INV12+INV14 loop)', async () => {
const { hosts, host, publicKeyRaw, privateKey, renewer } = await boundHost()
test('a revoked host cannot renew (INV12+INV14 loop)', async () => {
const { hosts, host, csr, renewer } = await hostRenewFixture('alice')
await hosts.setHostStatus(host.hostId, 'revoked')
await expect(renewer.renewHostLeaf(host.hostId, buildCsr(privateKey, publicKeyRaw))).rejects.toBeInstanceOf(LeafSignError)
await expect(renewer.renewHostLeaf(host.hostId, host.agentPubkey, csr)).rejects.toBeInstanceOf(LeafSignError)
})
test('the re-issued subdomain comes from the REGISTRY, never the CSR subject (no smuggling)', async () => {
const { host, keys, renewer } = await hostRenewFixture('alice')
// A malicious renewal CSR over the SAME key but with a foreign subject label.
const evilCsr = new Uint8Array(
(
await x509.Pkcs10CertificateRequestGenerator.create({
name: 'CN=bob.terminal.yaojia.wang',
keys,
signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' },
})
).rawData,
)
const { cert } = await renewer.renewHostLeaf(host.hostId, host.agentPubkey, evilCsr)
const names = new x509.X509Certificate(cert).getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON()
expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' }) // still alice
expect(names).not.toContainEqual({ type: 'dns', value: 'bob.terminal.yaojia.wang' })
})
})
// ── Device renewal setup ────────────────────────────────────────────────────────────────────────────
async function deviceRenewFixture(subdomainScope = 'alice', now?: () => number) {
const ca = await makeP256Ca('device-CA')
const store = createMemoryDeviceStore()
const registry = createDeviceRegistry({ devices: store, ...(now ? { now } : {}) })
const { der: csr, keys } = await buildCsrEc('CN=web-terminal-device')
const spki = await spkiOf(keys.publicKey)
const record = await registry.registerDevice({
accountId: randomUUID(),
subdomainScope,
ecPubkeySpki: spki,
attestationLevel: 'none',
})
const deviceSigner = createDeviceLeafSigner({
signer: ca.caSigner,
issuer: ca.caCert.subjectName,
caChainDer: [ca.caDer],
sanBaseDomain: BASE_DOMAIN,
trustDomain: TRUST_DOMAIN,
devices: store,
})
// Host signer is required by the renewer shape but unused in device tests — a throwaway.
const hostSigner = createFrpClientLeafSigner({
hosts: createMemoryStores().hosts,
signer: (await makeP256Ca('frp-client-CA')).caSigner,
issuerName: 'CN=frp-client-CA',
caChainDer: [],
trustDomain: TRUST_DOMAIN,
dnsZone: DNS_ZONE,
})
const renewer = createLeafRenewer({ hostSigner, deviceSigner, deviceExpiry: registry })
return { ca, store, registry, record, csr, spki, keys, renewer }
}
describe('A6 renewDeviceLeaf → REAL X.509 (FIX H-host-3)', () => {
test('active device + same-key CSR → a CA-verifiable X.509 leaf carrying the SAME subdomainScope', async () => {
const { ca, record, csr, renewer } = await deviceRenewFixture('alice')
const { cert, caChain, notAfter } = await renewer.renewDeviceLeaf(record.deviceId, csr)
const leaf = new x509.X509Certificate(cert)
const caPub = await importP256Public(ca.caSigner.publicKeyRaw)
expect(await leaf.verify({ publicKey: caPub, signatureOnly: true })).toBe(true)
const names = leaf.getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON()
expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' })
const uri = names.find((n) => n.type === 'url')!.value
expect(parseSpiffeId(uri)).toEqual({ accountId: record.accountId, id: record.deviceId, kind: 'device' })
expect(notAfter.getTime()).toBeGreaterThan(Date.now())
expect(caChain).toEqual([ca.caDer])
})
test('a CSR with a DIFFERENT key → rejected (MVP same-key)', async () => {
const { record, renewer } = await deviceRenewFixture('alice')
const { der: otherCsr } = await buildCsrEc('CN=web-terminal-device')
await expect(renewer.renewDeviceLeaf(record.deviceId, otherCsr)).rejects.toBeInstanceOf(DeviceLeafSignError)
})
test('a revoked device cannot renew', async () => {
const { registry, record, csr, renewer } = await deviceRenewFixture('alice')
await registry.setDeviceStatus(record.deviceId, 'revoked')
await expect(renewer.renewDeviceLeaf(record.deviceId, csr)).rejects.toBeInstanceOf(DeviceLeafSignError)
})
test('renewing TWICE across an advanced clock EXTENDS validity — the 2nd notAfter is later than the 1st', async () => {
// Regression for the H finding: the device signer stamps record.notAfter (set once at enroll), so
// without a per-renewal bump both renewals returned a byte-identical expiry — eventually in the
// PAST. Advance the registry clock a comfortable margin (X.509 notAfter is second-resolution) so
// the increase is unambiguous in the DER, then assert the second expiry strictly exceeds the first.
let clock = Date.now()
const { record, csr, renewer } = await deviceRenewFixture('alice', () => clock)
const first = await renewer.renewDeviceLeaf(record.deviceId, csr)
clock += 2 * DAY_MS
const second = await renewer.renewDeviceLeaf(record.deviceId, csr)
expect(second.notAfter.getTime()).toBeGreaterThan(first.notAfter.getTime())
// And the extension is REAL (roughly the clock advance), not a rounding artefact.
expect(second.notAfter.getTime() - first.notAfter.getTime()).toBeGreaterThanOrEqual(DAY_MS)
})
})

View File

@@ -0,0 +1,117 @@
/**
* A4 (session subsystem, FIX C-native-1) — the minimal device:enroll token mint/verify + the login
* stub seam. Proves: a minted token round-trips through relay-auth's REAL §4.3 verifier; a token
* lacking the 'enroll' right is rejected (403); a wrong audience is rejected (401); an expired token
* is rejected (401); and the single-tenant login stub resolves a credential → accountId (deny-by-default
* on a wrong / empty credential).
*/
import { describe, test, expect, beforeEach } from 'vitest'
import { configureVerifyKey } from 'relay-auth'
import { resetVerifyKeyForTest } from 'relay-auth/src/config/keys.js'
import { generateEd25519KeyPair } from 'relay-auth/src/crypto/ed25519.js'
import { signPaseto } from 'relay-auth/src/crypto/paseto.js'
import { randomBytes } from 'node:crypto'
import {
mintDeviceEnrollToken,
verifyDeviceEnrollToken,
loginToAccountId,
requireEnrollRight,
DeviceEnrollAuthError,
DEVICE_ENROLL_AUD,
} from '../src/auth/session.js'
const NOW = 1_700_000_000
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
/** 43-char base64url placeholder (satisfies the shared §4.3 verifier's `cnf.jkt` requirement). */
function jkt(): string {
return Buffer.from(randomBytes(32)).toString('base64url')
}
let signingKey: CryptoKey
beforeEach(async () => {
resetVerifyKeyForTest()
const pair = await generateEd25519KeyPair()
await configureVerifyKey(pair.publicKey)
signingKey = pair.privateKey
})
describe('A4 device:enroll session token', () => {
test('mint → verify round-trips and yields the accountId (rights:[enroll])', async () => {
const raw = await mintDeviceEnrollToken(ACCOUNT_A, { signingKey, now: NOW })
const { accountId } = await verifyDeviceEnrollToken(raw, { now: NOW + 5 })
expect(accountId).toBe(ACCOUNT_A)
})
test('a token missing the enroll right is rejected (403)', async () => {
// Mint an otherwise-valid device-enroll-aud token but with rights:['attach'] (no enroll).
const body = {
sub: ACCOUNT_A,
aud: DEVICE_ENROLL_AUD,
host: 'device-enroll',
rights: ['attach'],
iat: NOW,
exp: NOW + 600,
jti: 'jti-noenroll',
cnf: { jkt: jkt() },
}
const raw = await signPaseto(body, signingKey)
await expect(verifyDeviceEnrollToken(raw, { now: NOW + 5 })).rejects.toMatchObject({ status: 403 })
})
test('a token with the wrong audience is rejected (401)', async () => {
const raw = await mintDeviceEnrollToken(ACCOUNT_A, { signingKey, now: NOW, aud: 'not-device-enroll' })
await expect(verifyDeviceEnrollToken(raw, { now: NOW + 5 })).rejects.toMatchObject({ status: 401 })
})
test('an expired token is rejected (401)', async () => {
const raw = await mintDeviceEnrollToken(ACCOUNT_A, { signingKey, now: NOW, ttlSeconds: 60 })
await expect(verifyDeviceEnrollToken(raw, { now: NOW + 120 })).rejects.toMatchObject({ status: 401 })
})
test('a garbage token is rejected (401), uniform reject', async () => {
await expect(verifyDeviceEnrollToken('not-a-token', { now: NOW })).rejects.toBeInstanceOf(DeviceEnrollAuthError)
})
test('requireEnrollRight throws 403 without enroll, passes with it', () => {
const base = { sub: ACCOUNT_A, aud: DEVICE_ENROLL_AUD, host: 'x', iat: NOW, exp: NOW + 1, jti: 'j' }
expect(() => requireEnrollRight({ ...base, rights: ['attach'] })).toThrow(DeviceEnrollAuthError)
expect(() => requireEnrollRight({ ...base, rights: ['enroll'] })).not.toThrow()
})
test('ttl is minutes-scale (separate from the 3060s connect clamp)', async () => {
// A 10-minute token must still verify well past the 60s connect clamp horizon.
const raw = await mintDeviceEnrollToken(ACCOUNT_A, { signingKey, now: NOW })
const { accountId } = await verifyDeviceEnrollToken(raw, { now: NOW + 5 * 60 })
expect(accountId).toBe(ACCOUNT_A)
})
})
describe('A4 loginToAccountId stub seam (single-tenant MVP)', () => {
test('resolves an operator credential → accountId', () => {
const acct = loginToAccountId('op-secret', { operatorCredential: 'op-secret', accountId: ACCOUNT_A })
expect(acct).toBe(ACCOUNT_A)
})
test('rejects a wrong credential (401)', () => {
expect(() => loginToAccountId('wrong', { operatorCredential: 'op-secret', accountId: ACCOUNT_A })).toThrow(
DeviceEnrollAuthError,
)
})
test('rejects an empty credential (401)', () => {
expect(() => loginToAccountId('', { operatorCredential: 'op-secret', accountId: ACCOUNT_A })).toThrow(
DeviceEnrollAuthError,
)
})
test('supports an injected resolver seam (real login layer later)', () => {
const acct = loginToAccountId('pairing-xyz', { resolve: (c) => (c === 'pairing-xyz' ? ACCOUNT_A : null) })
expect(acct).toBe(ACCOUNT_A)
})
test('rejects when not configured (deny-by-default)', () => {
expect(() => loginToAccountId('anything', {})).toThrow(DeviceEnrollAuthError)
})
})

View File

@@ -0,0 +1,302 @@
/**
* A1 acceptance (FIX C-1) — the single X.509 issuance primitive. Proves that a leaf assembled by
* DER-encoding the TBS ourselves and signing the SERIALIZED TBS via `CaSigner.sign` (the KMS
* boundary) is a REAL, tool-parseable, signature-verifiable X.509 v3 certificate for BOTH the
* Ed25519 and ECDSA-P256 CA families — and that the dNSName+URI SAN the A3 nginx njs will parse
* round-trips byte-correct.
*/
import 'reflect-metadata'
import { describe, test, expect } from 'vitest'
import * as x509 from '@peculiar/x509'
import { AsnConvert } from '@peculiar/asn1-schema'
import { Certificate } from '@peculiar/asn1-x509'
import { webcrypto, generateKeyPairSync, randomBytes } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
assembleCertificate,
normalizeEcdsaSignatureToDer,
type AssembleCertificateInput,
} from '../src/ca/x509-assembler.js'
import { inProcessCaSigner, inProcessP256CaSigner, type CaSigner } from '../src/boot/ca-wiring.js'
x509.cryptoProvider.set(webcrypto)
const DAY_MS = 24 * 60 * 60 * 1000
const SAN_DNS = 'alice.terminal.yaojia.wang'
const SAN_URI = 'spiffe://relay.example.com/account/a1/host/alice'
/** 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<KeyPair> {
return (await webcrypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])) as unknown as KeyPair
}
const ED25519_SPKI_PREFIX = Uint8Array.from([
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
])
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`
}
function leafExtensions(): x509.Extension[] {
return [
new x509.SubjectAlternativeNameExtension([
{ type: 'dns', value: SAN_DNS },
{ type: 'url', value: SAN_URI },
]),
new x509.BasicConstraintsExtension(false, undefined, true),
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.clientAuth]),
]
}
async function importEd25519Public(raw: Uint8Array): Promise<CryptoKey> {
const spki = new Uint8Array(ED25519_SPKI_PREFIX.length + raw.length)
spki.set(ED25519_SPKI_PREFIX, 0)
spki.set(raw, ED25519_SPKI_PREFIX.length)
return webcrypto.subtle.importKey('spki', spki, { name: 'Ed25519' }, true, ['verify'])
}
async function importP256Public(spkiDer: Uint8Array): Promise<CryptoKey> {
// Copy into a fresh ArrayBuffer-backed view so it satisfies BufferSource (not ArrayBufferLike).
return webcrypto.subtle.importKey('spki', new Uint8Array(spkiDer), { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'])
}
async function ed25519SubjectSpki(): Promise<Uint8Array> {
const { publicKey } = generateKeyPairSync('ed25519')
return new Uint8Array(publicKey.export({ format: 'der', type: 'spki' }))
}
function baseInput(overrides: Partial<AssembleCertificateInput>): AssembleCertificateInput {
const now = Date.now()
return {
subjectPublicKey: new Uint8Array(0),
subject: 'CN=alice',
issuer: 'CN=test-CA',
serialNumber: randomBytes(16),
notBefore: new Date(now - 60_000),
notAfter: new Date(now + DAY_MS),
extensions: leafExtensions(),
signer: inProcessCaSigner(),
sigAlg: 'ed25519',
...overrides,
}
}
describe('x509-assembler — gate (a): assembled leaves re-parse as X.509', () => {
test('Ed25519 leaf re-parses via new x509.X509Certificate without throwing', async () => {
const der = await assembleCertificate(baseInput({ subjectPublicKey: await ed25519SubjectSpki() }))
expect(() => new x509.X509Certificate(der)).not.toThrow()
})
test('ECDSA-P256 leaf re-parses via new x509.X509Certificate without throwing', async () => {
const subject = await genP256()
const der = await assembleCertificate(baseInput({ subjectPublicKey: subject.publicKey, signer: inProcessP256CaSigner(), sigAlg: 'ecdsa-p256' }))
expect(() => new x509.X509Certificate(der)).not.toThrow()
expect(new x509.X509Certificate(der).subject).toBe('CN=alice')
})
})
describe('x509-assembler — gate (b): re-parsed leaf signature verifies against the CA public key', () => {
test('Ed25519 leaf verifies against the Ed25519 CA public key', async () => {
const ca = inProcessCaSigner()
const der = await assembleCertificate(baseInput({ subjectPublicKey: await ed25519SubjectSpki(), signer: ca, sigAlg: 'ed25519' }))
const leaf = new x509.X509Certificate(der)
const caPub = await importEd25519Public(ca.publicKeyRaw)
expect(await leaf.verify({ publicKey: caPub, signatureOnly: true })).toBe(true)
})
test('ECDSA-P256 leaf verifies against the P-256 CA public key', async () => {
const ca = inProcessP256CaSigner()
const subject = await genP256()
const der = await assembleCertificate(baseInput({ subjectPublicKey: subject.publicKey, signer: ca, sigAlg: 'ecdsa-p256' }))
const leaf = new x509.X509Certificate(der)
const caPub = await importP256Public(ca.publicKeyRaw)
expect(await leaf.verify({ publicKey: caPub, signatureOnly: true })).toBe(true)
})
test('a leaf does NOT verify against a different CA key (negative)', async () => {
const ca = inProcessP256CaSigner()
const subject = await genP256()
const der = await assembleCertificate(baseInput({ subjectPublicKey: subject.publicKey, signer: ca, sigAlg: 'ecdsa-p256' }))
const leaf = new x509.X509Certificate(der)
const otherCaPub = await importP256Public(inProcessP256CaSigner().publicKeyRaw)
expect(await leaf.verify({ publicKey: otherCaPub, signatureOnly: true })).toBe(false)
})
})
describe('x509-assembler — gate (c): dNSName + URI SAN round-trips byte-correct', () => {
test('Ed25519 leaf SAN carries both the dNSName and the URI', async () => {
const der = await assembleCertificate(baseInput({ subjectPublicKey: await ed25519SubjectSpki() }))
const san = new x509.X509Certificate(der).getExtension(x509.SubjectAlternativeNameExtension)
const names = san!.names.toJSON()
expect(names).toContainEqual({ type: 'dns', value: SAN_DNS })
expect(names).toContainEqual({ type: 'url', value: SAN_URI })
})
test('ECDSA-P256 leaf SAN carries both the dNSName and the URI', async () => {
const subject = await genP256()
const der = await assembleCertificate(baseInput({ subjectPublicKey: subject.publicKey, signer: inProcessP256CaSigner(), sigAlg: 'ecdsa-p256' }))
const san = new x509.X509Certificate(der).getExtension(x509.SubjectAlternativeNameExtension)
const names = san!.names.toJSON()
expect(names).toContainEqual({ type: 'dns', value: SAN_DNS })
expect(names).toContainEqual({ type: 'url', value: SAN_URI })
})
})
describe('x509-assembler — X.509 structural invariants', () => {
test('tbsCertificate.signature OID equals certificate.signatureAlgorithm OID (identical)', async () => {
const der = await assembleCertificate(baseInput({ subjectPublicKey: await ed25519SubjectSpki() }))
const cert = AsnConvert.parse(der.buffer.slice(der.byteOffset, der.byteOffset + der.byteLength) as ArrayBuffer, Certificate)
expect(cert.tbsCertificate.signature.algorithm).toBe(cert.signatureAlgorithm.algorithm)
expect(cert.signatureAlgorithm.algorithm).toBe('1.3.101.112')
})
test('ECDSA-P256 signatureAlgorithm OID is ecdsa-with-SHA256', async () => {
const subject = await genP256()
const der = await assembleCertificate(baseInput({ subjectPublicKey: subject.publicKey, signer: inProcessP256CaSigner(), sigAlg: 'ecdsa-p256' }))
const cert = AsnConvert.parse(der.buffer.slice(der.byteOffset, der.byteOffset + der.byteLength) as ArrayBuffer, Certificate)
expect(cert.tbsCertificate.signature.algorithm).toBe('1.2.840.10045.4.3.2')
expect(cert.signatureAlgorithm.algorithm).toBe('1.2.840.10045.4.3.2')
})
test('a high-bit serial is emitted as a positive INTEGER (0x00-prefixed content octets)', async () => {
const highBit = Uint8Array.from([0x80, 0x01, 0x02, 0x03])
const der = await assembleCertificate(baseInput({ subjectPublicKey: await ed25519SubjectSpki(), serialNumber: highBit }))
// Inspect the raw INTEGER content octets: the assembler MUST prepend 0x00 so a leading 0x80 is
// never read as a negative integer. (@peculiar's serialNumber getter strips it for display.)
const cert = AsnConvert.parse(der.buffer.slice(der.byteOffset, der.byteOffset + der.byteLength) as ArrayBuffer, Certificate)
const serialBytes = new Uint8Array(cert.tbsCertificate.serialNumber)
expect(Array.from(serialBytes)).toEqual([0x00, 0x80, 0x01, 0x02, 0x03])
})
test('subject public key accepted as BOTH a CryptoKey and as SPKI DER', async () => {
const kp = await genP256()
const spkiDer = new Uint8Array(await webcrypto.subtle.exportKey('spki', kp.publicKey))
const ca = inProcessP256CaSigner()
const fromKey = await assembleCertificate(baseInput({ subjectPublicKey: kp.publicKey, signer: ca, sigAlg: 'ecdsa-p256' }))
const fromDer = await assembleCertificate(baseInput({ subjectPublicKey: spkiDer, signer: ca, sigAlg: 'ecdsa-p256' }))
// Both embed the SAME subjectPublicKeyInfo.
expect(new x509.X509Certificate(fromKey).publicKey.rawData.byteLength).toBe(new x509.X509Certificate(fromDer).publicKey.rawData.byteLength)
expect(Buffer.from(new x509.X509Certificate(fromKey).publicKey.rawData).equals(Buffer.from(new x509.X509Certificate(fromDer).publicKey.rawData))).toBe(true)
})
})
describe('normalizeEcdsaSignatureToDer — both signer output shapes', () => {
test('raw P1363 (64 bytes) is converted to a valid DER ECDSA-Sig-Value', async () => {
const kp = generateKeyPairSync('ec', { namedCurve: 'P-256' })
const { sign } = await import('node:crypto')
const raw = new Uint8Array(sign('sha256', Buffer.from('hello'), { key: kp.privateKey, dsaEncoding: 'ieee-p1363' }))
expect(raw.length).toBe(64)
const der = normalizeEcdsaSignatureToDer(raw)
expect(der[0]).toBe(0x30) // SEQUENCE
// openssl-shape signature verifies against the same key (proves the r,s survived intact).
const { verify } = await import('node:crypto')
expect(verify('sha256', Buffer.from('hello'), { key: kp.publicKey, dsaEncoding: 'der' }, Buffer.from(der))).toBe(true)
})
test('an already-DER ECDSA-Sig-Value passes through unchanged', async () => {
const kp = generateKeyPairSync('ec', { namedCurve: 'P-256' })
const { sign } = await import('node:crypto')
const der = new Uint8Array(sign('sha256', Buffer.from('world'), { key: kp.privateKey, dsaEncoding: 'der' }))
const out = normalizeEcdsaSignatureToDer(der)
expect(Buffer.from(out).equals(Buffer.from(der))).toBe(true)
})
test('garbage that is neither P1363 nor DER throws (fail loud at issuance)', () => {
expect(() => normalizeEcdsaSignatureToDer(Uint8Array.from([1, 2, 3, 4, 5]))).toThrow()
})
})
describe('x509-assembler — Ed25519 signatureValue length guard (CP2)', () => {
/** A CaSigner whose `sign` returns a wrong-length "Ed25519" signature (truncated / malformed). */
function fixedLenEd25519Signer(len: number): CaSigner {
return { publicKeyRaw: new Uint8Array(32), async sign() { return new Uint8Array(len) } }
}
test('a signer returning fewer than 64 bytes is rejected at issuance (not embedded)', async () => {
await expect(
assembleCertificate(
baseInput({ subjectPublicKey: await ed25519SubjectSpki(), signer: fixedLenEd25519Signer(63), sigAlg: 'ed25519' }),
),
).rejects.toThrow(/64 bytes/)
})
test('a signer returning more than 64 bytes is rejected too', async () => {
await expect(
assembleCertificate(
baseInput({ subjectPublicKey: await ed25519SubjectSpki(), signer: fixedLenEd25519Signer(65), sigAlg: 'ed25519' }),
),
).rejects.toThrow(/64 bytes/)
})
test('a real 64-byte Ed25519 signature still issues fine (no false positive)', async () => {
const der = await assembleCertificate(baseInput({ subjectPublicKey: await ed25519SubjectSpki() }))
expect(() => new x509.X509Certificate(der)).not.toThrow()
})
})
describe('x509-assembler — gate (e): openssl parses & verifies the chain', () => {
function hasOpenssl(): boolean {
try {
execFileSync('openssl', ['version'], { stdio: 'ignore' })
return true
} catch {
return false
}
}
test('openssl x509 -text parses the P-256 leaf and openssl verify OKs it against the CA', async () => {
if (!hasOpenssl()) {
// openssl absent — gates (a)-(c) already prove real X.509; note and skip the tool check.
expect(true).toBe(true)
return
}
// Self-signed P-256 CA (subject key == signer key) so openssl can build a full chain.
const caKeys = generateKeyPairSync('ec', { namedCurve: 'P-256' })
const caSpki = new Uint8Array(caKeys.publicKey.export({ format: 'der', type: 'spki' }))
const caSigner: CaSigner = inProcessP256CaSigner(caKeys.privateKey)
const now = Date.now()
const caDer = await assembleCertificate({
subjectPublicKey: caSpki,
subject: 'CN=Test P256 CA',
issuer: 'CN=Test P256 CA',
serialNumber: Uint8Array.from([0x01]),
notBefore: new Date(now - DAY_MS),
notAfter: new Date(now + 365 * DAY_MS),
extensions: [
new x509.BasicConstraintsExtension(true, undefined, true),
new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true),
],
signer: caSigner,
sigAlg: 'ecdsa-p256',
})
const subject = await genP256()
const leafDer = await assembleCertificate(baseInput({ subjectPublicKey: subject.publicKey, issuer: 'CN=Test P256 CA', signer: caSigner, sigAlg: 'ecdsa-p256' }))
const dir = mkdtempSync(join(tmpdir(), 'x509asm-'))
try {
const caPem = join(dir, 'ca.pem')
const leafPem = join(dir, 'leaf.pem')
writeFileSync(caPem, derToPem(caDer))
writeFileSync(leafPem, derToPem(leafDer))
const text = execFileSync('openssl', ['x509', '-in', leafPem, '-noout', '-text'], { encoding: 'utf8' })
expect(text).toContain('Signature Algorithm: ecdsa-with-SHA256')
expect(text).toContain(SAN_DNS)
expect(text).toContain(SAN_URI)
const verifyOut = execFileSync('openssl', ['verify', '-CAfile', caPem, leafPem], { encoding: 'utf8' })
expect(verifyOut).toContain('OK')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})