From 6efed9772e3e9df734a7dd2da0557bff89480957 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Mon, 6 Jul 2026 20:46:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(control-plane):=20real=20X.509=20agent=20l?= =?UTF-8?q?eaf=20issuance=20(closes=20enrollment=E2=86=94mTLS=20gap)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ca/issue.ts: real X.509 v3 Ed25519 leaf with SPIFFE URI SAN, signed by the intermediate; SAN built via relay-auth spiffeIdFor so it can't drift - ca/csr.ts: parse real PKCS#10 (agent's PEM) + async PoP verify; decodeCsrWire accepts PEM or base64(DER) - main.ts/env.ts: real issuer when CA_INTERMEDIATE_KEY_PATH present, else dev fallback - interop.test.ts: oracle proving relay-auth verifyAgentCert accepts the leaf (6 tests) - deps: @peculiar/x509, reflect-metadata --- control-plane/package-lock.json | 218 ++++++++++++++++++++++++++- control-plane/package.json | 6 +- control-plane/src/api/provision.ts | 7 +- control-plane/src/ca/csr.ts | 176 ++++++++++++++++------ control-plane/src/ca/issue.ts | 125 ++++++++++++++++ control-plane/src/ca/rotate.ts | 2 +- control-plane/src/ca/sign.ts | 74 +++++++--- control-plane/src/env.ts | 33 +++++ control-plane/src/main.ts | 40 ++++- control-plane/src/pairing/redeem.ts | 2 +- control-plane/test/ca.test.ts | 2 +- control-plane/test/interop.test.ts | 220 ++++++++++++++++++++++++++++ 12 files changed, 825 insertions(+), 80 deletions(-) create mode 100644 control-plane/src/ca/issue.ts create mode 100644 control-plane/test/interop.test.ts diff --git a/control-plane/package-lock.json b/control-plane/package-lock.json index f0d8e4c..6afa87e 100644 --- a/control-plane/package-lock.json +++ b/control-plane/package-lock.json @@ -8,9 +8,11 @@ "name": "control-plane", "version": "0.0.0", "dependencies": { + "@peculiar/x509": "^2.0.0", "fastify": "^4.28.1", "ioredis": "^5.4.1", "pg": "^8.12.0", + "reflect-metadata": "^0.2.2", "relay-auth": "file:../relay-auth", "relay-contracts": "file:../relay-contracts", "zod": "^3.23.8" @@ -691,6 +693,162 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", + "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz", + "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz", + "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz", + "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-rsa": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz", + "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz", + "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pfx": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz", + "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", + "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz", + "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.0.0.tgz", + "integrity": "sha512-r10lkuy6BNfRmyYdRAfgu6dq0HOmyIV2OLhXWE3gDEPBdX1b8miztJVyX/UxWhLwemNyDP3CLZHpDxDwSY0xaA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -1225,6 +1383,20 @@ ], "license": "BSD-3-Clause" }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2293,6 +2465,24 @@ "node": ">= 0.10" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -2329,6 +2519,12 @@ "node": ">=4" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/relay-auth": { "resolved": "../relay-auth", "link": true @@ -2581,9 +2777,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.23.0", @@ -2604,6 +2798,24 @@ "fsevents": "~2.3.3" } }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", diff --git a/control-plane/package.json b/control-plane/package.json index 970d109..091ffe2 100644 --- a/control-plane/package.json +++ b/control-plane/package.json @@ -16,11 +16,13 @@ "coverage": "vitest run --coverage" }, "dependencies": { - "relay-contracts": "file:../relay-contracts", - "relay-auth": "file:../relay-auth", + "@peculiar/x509": "^2.0.0", "fastify": "^4.28.1", "ioredis": "^5.4.1", "pg": "^8.12.0", + "reflect-metadata": "^0.2.2", + "relay-auth": "file:../relay-auth", + "relay-contracts": "file:../relay-contracts", "zod": "^3.23.8" }, "devDependencies": { diff --git a/control-plane/src/api/provision.ts b/control-plane/src/api/provision.ts index fdfdbbf..2827c51 100644 --- a/control-plane/src/api/provision.ts +++ b/control-plane/src/api/provision.ts @@ -15,6 +15,7 @@ import type { HostRegistry } from '../registry/hosts.js' import type { PairingIssuer } from '../pairing/issue.js' import type { PairingRedeemer } from '../pairing/redeem.js' import { RedeemError } from '../pairing/redeem.js' +import { decodeCsrWire } from '../ca/csr.js' import type { Deprovisioner } from '../deprovision/deprovision.js' export interface ProvisionDeps { @@ -30,8 +31,8 @@ const PlanSchema = z.enum(['free', 'personal', 'pro', 'team']) const StatusSchema = z.object({ status: z.enum(['active', 'suspended']) }) const EnrollSchema = z.object({ code: z.string().min(1), - agentPubkey: z.string().min(1), // base64 - csr: z.string().min(1), // base64 + agentPubkey: z.string().min(1), // base64 (agent sends base64url; Buffer decodes both) + csr: z.string().min(1), // PKCS#10: PEM block (agent) or base64(DER) — see decodeCsrWire }) function sendError(reply: FastifyReply, err: unknown): void { @@ -126,7 +127,7 @@ export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync { const result = await deps.redeemer.redeemPairingCode({ code: body.code, agentPubkey: new Uint8Array(Buffer.from(body.agentPubkey, 'base64')), - csr: new Uint8Array(Buffer.from(body.csr, 'base64')), + csr: decodeCsrWire(body.csr), }) await reply.code(201).send({ ...result, hostContentSecret: Buffer.from(result.hostContentSecret).toString('base64') }) } catch (err) { diff --git a/control-plane/src/ca/csr.ts b/control-plane/src/ca/csr.ts index 6bbfe60..21c97eb 100644 --- a/control-plane/src/ca/csr.ts +++ b/control-plane/src/ca/csr.ts @@ -1,53 +1,147 @@ /** - * CSR proof-of-possession (INV14). Models a PKCS#10 CSR as `embeddedPub(32) || sig(64)` where - * `sig = Ed25519(privateKey, CSR_CHALLENGE || embeddedPub)`. Verifying the signature against the - * embedded pubkey proves the requester holds the matching private key (the exact property a real - * PKCS#10 self-signature provides) — using builtin crypto only. + * CSR handling (INV14) — REAL PKCS#10 over the agent's Ed25519 identity. * - * INTEGRATION SEAM: production parses a real PKCS#10 DER (`@peculiar/x509`) and checks the - * self-signature. Swapping it in does not change `signHostLeaf`'s check ORDER or reject path. + * The agent (`agent/src/enroll/csr.ts`) emits a standard PKCS#10 `CertificationRequest` signed by + * its in-process Ed25519 key. This module parses that request with `@peculiar/x509`, verifies its + * self-signature (proof-of-possession — the requester holds the private key for the embedded + * pubkey), and exposes the embedded raw Ed25519 public key so `sign.ts` can gate on + * embeddedPub == agentPubkey. Parsing/verification is FAIL-CLOSED and returns a UNIFORM result: any + * malformed/forged input yields `{ ok: false }` with no distinguishing detail (see `verifyCsrPoP`). + * + * `@peculiar/x509` uses `tsyringe`, which requires the `reflect-metadata` polyfill to be loaded + * before the library — hence the side-effect import on the first line (must stay first). */ -import { ed25519Sign, ed25519Verify, type KeyObject } from '../util/crypto.js' +import 'reflect-metadata' +import * as x509 from '@peculiar/x509' +import { webcrypto } from 'node:crypto' +import { ed25519Sign, type KeyObject } from '../util/crypto.js' -const CSR_CHALLENGE = new TextEncoder().encode('relay-cp/csr-pop/v1|') +// Ed25519 signing/verification runs on Node's WebCrypto (Ed25519 supported on Node 20+). +x509.cryptoProvider.set(webcrypto) -/** Test/agent helper: build a CSR proving possession of `privateKey` for `embeddedPub`. */ -export function buildCsr(privateKey: KeyObject, embeddedPub: Uint8Array): Uint8Array { - const message = concat(CSR_CHALLENGE, embeddedPub) - const sig = ed25519Sign(privateKey, message) - return concat(embeddedPub, sig) -} +// --- Ed25519 SPKI constants ------------------------------------------------------------------- +/** Fixed 12-byte SPKI prefix for an Ed25519 SubjectPublicKeyInfo; the raw key is the trailing 32. */ +const ED25519_SPKI_PREFIX = Uint8Array.from([ + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, +]) +const ED25519_SPKI_LEN = ED25519_SPKI_PREFIX.length + 32 // 44 +const RAW_ED25519_LEN = 32 -export interface CsrParts { - readonly embeddedPub: Uint8Array - readonly sig: Uint8Array -} +// --- minimal DER encoding helpers (test/agent-side CSR construction) --------------------------- -/** Parse the modelled CSR bytes. Throws on wrong length. */ -export function parseCsr(csr: Uint8Array): CsrParts { - if (csr.length !== 96) throw new Error('malformed csr') - return { embeddedPub: csr.subarray(0, 32), sig: csr.subarray(32, 96) } -} - -/** - * Verify CSR proof-of-possession: the embedded pubkey's private key signed the challenge. - * Independent of any registry state (a forged signature is refused even for a registered key). - */ -export function verifyCsrPoP(csr: Uint8Array): { ok: boolean; embeddedPub: Uint8Array } { - let parts: CsrParts - try { - parts = parseCsr(csr) - } catch { - return { ok: false, embeddedPub: new Uint8Array(0) } +function derLen(len: number): Uint8Array { + if (len < 0x80) return Uint8Array.from([len]) + const bytes: number[] = [] + let n = len + while (n > 0) { + bytes.unshift(n & 0xff) + n >>= 8 } - const message = concat(CSR_CHALLENGE, parts.embeddedPub) - const ok = ed25519Verify(parts.embeddedPub, message, parts.sig) - return { ok, embeddedPub: parts.embeddedPub } + return Uint8Array.from([0x80 | bytes.length, ...bytes]) } -function concat(a: Uint8Array, b: Uint8Array): Uint8Array { - const out = new Uint8Array(a.length + b.length) - out.set(a, 0) - out.set(b, a.length) +function tlv(tag: number, value: Uint8Array): Uint8Array { + const len = derLen(value.length) + const out = new Uint8Array(1 + len.length + value.length) + out[0] = tag + out.set(len, 1) + out.set(value, 1 + len.length) return out } + +function concat(chunks: readonly Uint8Array[]): Uint8Array { + const out = new Uint8Array(chunks.reduce((s, c) => s + c.length, 0)) + let off = 0 + for (const c of chunks) { + out.set(c, off) + off += c.length + } + return out +} + +const SEQUENCE = 0x30 +const SET = 0x31 +const INTEGER = 0x02 +const BIT_STRING = 0x03 +const OID = 0x06 +const UTF8_STRING = 0x0c +const CONTEXT_0 = 0xa0 +const OID_CN = Uint8Array.from([0x55, 0x04, 0x03]) // 2.5.4.3 commonName +const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70]) // 1.3.101.112 Ed25519 + +function spkiFromRawEd25519(raw: Uint8Array): Uint8Array { + const algId = tlv(SEQUENCE, tlv(OID, OID_ED25519)) + const pubBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), raw])) + return tlv(SEQUENCE, concat([algId, pubBits])) +} + +function nameFromCn(cn: string): Uint8Array { + const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))])) + return tlv(SEQUENCE, tlv(SET, atv)) +} + +/** + * Build a real PKCS#10 `CertificationRequest` DER for `embeddedPub`, self-signed by `privateKey` + * (Ed25519). Mirrors the agent's on-wire request shape (subject CN, empty attributes). Used by the + * control-plane's own tests to exercise the real parse/verify path; production requests come from + * the agent unchanged. + */ +export function buildCsr(privateKey: KeyObject, embeddedPub: Uint8Array): Uint8Array { + const version = tlv(INTEGER, Uint8Array.from([0x00])) + const requestInfo = tlv( + SEQUENCE, + concat([version, nameFromCn('web-terminal-agent'), spkiFromRawEd25519(embeddedPub), tlv(CONTEXT_0, new Uint8Array(0))]), + ) + const signature = ed25519Sign(privateKey, requestInfo) + const sigAlg = tlv(SEQUENCE, tlv(OID, OID_ED25519)) + const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature])) + return tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits])) +} + +// --- wire decoding + verification -------------------------------------------------------------- + +const PEM_CSR_RE = /-----BEGIN CERTIFICATE REQUEST-----([\s\S]+?)-----END CERTIFICATE REQUEST-----/ + +/** + * Decode the `csr` field as it arrives on the `/enroll` wire into raw PKCS#10 DER bytes. Accepts + * BOTH shapes the codebase produces: the agent sends a PEM `CERTIFICATE REQUEST` block, while the + * control-plane's own HTTP tests send `base64(DER)`. Both normalise to the same DER — this is the + * single place the wire encoding is interpreted (boundary validation, coding-style §Input). + */ +export function decodeCsrWire(wire: string): Uint8Array { + const pem = PEM_CSR_RE.exec(wire) + const body = pem !== null ? pem[1]!.replace(/\s+/g, '') : wire.trim() + return new Uint8Array(Buffer.from(body, 'base64')) +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer +} + +/** Extract the raw 32-byte Ed25519 key from a SubjectPublicKeyInfo DER, or null if not Ed25519. */ +function rawEd25519FromSpki(spki: Uint8Array): Uint8Array | null { + if (spki.length !== ED25519_SPKI_LEN) return null + for (let i = 0; i < ED25519_SPKI_PREFIX.length; i++) { + if (spki[i] !== ED25519_SPKI_PREFIX[i]) return null + } + return spki.subarray(ED25519_SPKI_PREFIX.length) +} + +/** + * Verify CSR proof-of-possession: parse the PKCS#10 DER and check its self-signature against the + * embedded public key. Returns the embedded raw Ed25519 pubkey on success. FAIL-CLOSED and uniform: + * malformed DER, a non-Ed25519 key, or a bad signature all yield `{ ok: false, embeddedPub: [] }` + * with no leak of which check failed. Independent of any registry state. + */ +export async function verifyCsrPoP(csr: Uint8Array): Promise<{ ok: boolean; embeddedPub: Uint8Array }> { + const fail = { ok: false, embeddedPub: new Uint8Array(0) } + try { + const req = new x509.Pkcs10CertificateRequest(toArrayBuffer(csr)) + const embeddedPub = rawEd25519FromSpki(new Uint8Array(req.publicKey.rawData)) + if (embeddedPub === null || embeddedPub.length !== RAW_ED25519_LEN) return fail + const ok = await req.verify() + return ok ? { ok: true, embeddedPub: new Uint8Array(embeddedPub) } : fail + } catch { + return fail + } +} diff --git a/control-plane/src/ca/issue.ts b/control-plane/src/ca/issue.ts new file mode 100644 index 0000000..8eebaf5 --- /dev/null +++ b/control-plane/src/ca/issue.ts @@ -0,0 +1,125 @@ +/** + * Real X.509 leaf issuance (INV14). After the shared `assertLeafGate` passes, emit an X.509 v3 + * Ed25519 leaf whose subject key is the enrolled agent pubkey and whose only SAN is the host's + * SPIFFE-ID URI — signed by the intermediate Ed25519 key. This is what `relay-auth`'s + * `verifyAgentCert` accepts (it walks leaf → intermediate → self-signed root and parses the + * `URI:spiffe://relay./account//host/` SAN). + * + * The SPIFFE-ID is built with relay-auth's OWN builder (`spiffeIdFor`, deep-imported) so the emitted + * SAN can never drift from the verifier's parser. The intermediate PRIVATE key is a WebCrypto + * `CryptoKey` imported non-extractable (never serialised back out — INV9). + * + * `reflect-metadata` must load before `@peculiar/x509` (tsyringe polyfill) — keep it first. + */ +import 'reflect-metadata' +import * as x509 from '@peculiar/x509' +import { webcrypto, randomBytes } from 'node:crypto' +import { spiffeIdFor } from 'relay-auth/src/agent/spiffe.js' +import type { HostStore } from '../store/ports.js' +import { assertLeafGate, DEFAULT_LEAF_TTL_SEC, type LeafSigner } from './sign.js' + +x509.cryptoProvider.set(webcrypto) + +/** Backdate notBefore slightly to tolerate small clock skew between control-plane and relay. */ +const CLOCK_SKEW_SEC = 60 + +export interface RealLeafSignerDeps { + readonly hosts: HostStore + /** Intermediate Ed25519 PRIVATE signing key (WebCrypto, non-extractable). */ + readonly intermediateKey: CryptoKey + /** Intermediate subject as a Name — used verbatim as the leaf issuer so `checkIssued` matches. */ + readonly issuerName: x509.Name + /** DER of [intermediate, root] returned to the agent as its CA bundle (INV14). */ + readonly caChainDer: readonly Uint8Array[] + /** Bare trust domain; the SPIFFE builder prepends `relay.`. */ + readonly trustDomain: string + readonly leafTtlSec?: number +} + +/** Import a raw 32-byte Ed25519 public key as a verifying WebCrypto CryptoKey (via SPKI DER). */ +async function importEd25519Public(raw: Uint8Array): Promise { + const prefix = Uint8Array.from([ + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, + ]) + const spki = new Uint8Array(prefix.length + raw.length) + spki.set(prefix, 0) + spki.set(raw, prefix.length) + return webcrypto.subtle.importKey('spki', spki, { name: 'Ed25519' }, true, ['verify']) +} + +/** + * Build the production leaf signer. Every issued leaf: X.509 v3, Ed25519 subject = agentPubkey, + * URI SAN = the host's SPIFFE-ID, CA:false, KeyUsage digitalSignature, EKU clientAuth, validity + * [now-skew, now+ttl]. Returns leaf DER + the injected CA chain DER; `redeem.ts` PEM-wraps both. + */ +export function createRealLeafSigner(deps: RealLeafSignerDeps): LeafSigner { + const ttl = deps.leafTtlSec ?? DEFAULT_LEAF_TTL_SEC + return { + async signHostLeaf(hostId, agentPubkey, csr) { + const host = await assertLeafGate(deps.hosts, hostId, agentPubkey, csr) + const spiffe = spiffeIdFor(host.accountId, host.hostId, deps.trustDomain) + const subjectKey = await importEd25519Public(agentPubkey) + const now = Date.now() + const leaf = await x509.X509CertificateGenerator.create({ + serialNumber: randomBytes(16).toString('hex'), + subject: `CN=${host.hostId}`, + issuer: deps.issuerName, + notBefore: new Date(now - CLOCK_SKEW_SEC * 1000), + notAfter: new Date(now + ttl * 1000), + publicKey: subjectKey, + signingKey: deps.intermediateKey, + signingAlgorithm: { name: 'Ed25519' }, + extensions: [ + new x509.SubjectAlternativeNameExtension([{ type: 'url', value: spiffe }]), + new x509.BasicConstraintsExtension(false, undefined, true), + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true), + new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.clientAuth]), + ], + }) + return { cert: new Uint8Array(leaf.rawData), caChain: deps.caChainDer } + }, + } +} + +export interface LoadRealLeafSignerInput { + readonly hosts: HostStore + /** Intermediate Ed25519 private key, PKCS#8 PEM. */ + readonly intermediateKeyPem: string + /** Intermediate certificate, PEM (single block). */ + readonly intermediateCertPem: string + /** Self-signed root certificate, PEM (single block). */ + readonly rootCertPem: string + readonly trustDomain: string + readonly leafTtlSec?: number +} + +function pemToDer(pem: string): ArrayBuffer { + const body = pem.replace(/-----BEGIN [^-]+-----/g, '').replace(/-----END [^-]+-----/g, '').replace(/\s+/g, '') + const bytes = Buffer.from(body, 'base64') + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer +} + +/** + * Construct a real leaf signer from PEM material (boot path). Imports the intermediate private key + * (non-extractable — never re-serialised, INV9) and derives the issuer Name + CA chain DER from the + * certs. THROWS on unreadable/malformed material so the control-plane fails fast at boot. + */ +export async function loadRealLeafSigner(input: LoadRealLeafSignerInput): Promise { + const intermediateKey = await webcrypto.subtle.importKey( + 'pkcs8', + pemToDer(input.intermediateKeyPem), + { name: 'Ed25519' }, + false, // non-extractable: the raw private key can never leave the process (INV9) + ['sign'], + ) + const intermediateCert = new x509.X509Certificate(input.intermediateCertPem) + const rootCert = new x509.X509Certificate(input.rootCertPem) + return createRealLeafSigner({ + hosts: input.hosts, + intermediateKey, + issuerName: intermediateCert.subjectName, + caChainDer: [new Uint8Array(intermediateCert.rawData), new Uint8Array(rootCert.rawData)], + trustDomain: input.trustDomain, + ...(input.leafTtlSec !== undefined ? { leafTtlSec: input.leafTtlSec } : {}), + }) +} diff --git a/control-plane/src/ca/rotate.ts b/control-plane/src/ca/rotate.ts index c401cbd..229165e 100644 --- a/control-plane/src/ca/rotate.ts +++ b/control-plane/src/ca/rotate.ts @@ -36,7 +36,7 @@ export function createLeafRenewer(deps: LeafRenewerDeps): LeafRenewer { // A revoked/absent host cannot renew (INV12 + INV14). if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered') - const pop = verifyCsrPoP(csr) + const pop = await verifyCsrPoP(csr) if (!pop.ok) throw new LeafSignError('csr_rejected') // embedded pubkey must equal the host's REGISTERED pubkey (no key substitution on renewal). if (!timingSafeEqualBytes(pop.embeddedPub, host.agentPubkey)) throw new LeafSignError('csr_rejected') diff --git a/control-plane/src/ca/sign.ts b/control-plane/src/ca/sign.ts index 7cfda2b..70f5cd0 100644 --- a/control-plane/src/ca/sign.ts +++ b/control-plane/src/ca/sign.ts @@ -1,13 +1,19 @@ /** - * T8 — bind-time mTLS leaf signer, registry-gated (INV14 registry half). ORDER OF CHECKS (all + * T8 — bind-time mTLS leaf signing, registry-gated (INV14 registry half). ORDER OF CHECKS (all * must pass, SAME reject path): - * 1. CSR proof-of-possession — verify the CSR self-signature against its embedded pubkey. + * 1. CSR proof-of-possession — verify the PKCS#10 self-signature against its embedded pubkey. * 2. embedded pubkey == caller-supplied `agentPubkey` (no substitution). * 3. (hostId, agentPubkey) is an ACTIVE, non-revoked row in the host registry. - * A failure at ANY step rejects identically; the KMS `sign()` is NEVER invoked when any check - * fails. Signing itself is `CaSigner.sign()` (KMS, §3.1) — never a raw private key in memory. + * A failure at ANY step rejects identically; issuance is NEVER reached when any check fails. + * + * Two `LeafSigner` implementations share the `assertLeafGate` gate below: + * - `createLeafSigner` (this file) — DEV/legacy placeholder cert (a signed JSON blob, NOT X.509); + * used only when no real CA material is configured (see `main.ts` fallback). + * - `createRealLeafSigner` (`ca/issue.ts`) — the production issuer that emits a real X.509 v3 + * Ed25519 leaf with a SPIFFE SAN, signed by the intermediate key. */ import type { HostStore } from '../store/ports.js' +import type { HostRecord } from '../model/records.js' import type { CaSigner } from '../boot/ca-wiring.js' import { verifyCsrPoP } from './csr.js' import { timingSafeEqualBytes, bytesToBase64 } from '../util/bytes.js' @@ -18,14 +24,6 @@ export class LeafSignError extends Error { } } -export interface LeafSignerDeps { - readonly hosts: HostStore - readonly signer: CaSigner - readonly caChainDer: readonly Uint8Array[] - /** Leaf validity in seconds (short-lived, INV14). Default 24h. */ - readonly leafTtlSec?: number -} - export interface LeafSigner { signHostLeaf( hostId: string, @@ -34,28 +32,56 @@ export interface LeafSigner { ): Promise<{ cert: Uint8Array; caChain: readonly Uint8Array[] }> } -const DEFAULT_LEAF_TTL_SEC = 24 * 60 * 60 +/** Leaf validity in seconds (short-lived, INV14). Default 24h. */ +export const DEFAULT_LEAF_TTL_SEC = 24 * 60 * 60 +/** + * Shared registry + proof-of-possession gate (INV14). Runs the three ordered checks and, on + * success, returns the gated host record so the issuer can build the SPIFFE SAN from the + * authoritative `accountId`/`hostId` binding. Throws `LeafSignError` (uniform) on any failure. + */ +export async function assertLeafGate( + hosts: HostStore, + hostId: string, + agentPubkey: Uint8Array, + csr: Uint8Array, +): Promise { + // 1. proof-of-possession (independent of registry state) + const pop = await verifyCsrPoP(csr) + if (!pop.ok) throw new LeafSignError('csr_rejected') + // 2. no substitution: CSR pubkey must equal the presented agentPubkey + if (!timingSafeEqualBytes(pop.embeddedPub, agentPubkey)) throw new LeafSignError('csr_rejected') + // 3. registry gate: host bound, active, non-revoked, pubkey matches (INV14) + const host = await hosts.get(hostId) + if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered') + if (!timingSafeEqualBytes(host.agentPubkey, agentPubkey)) throw new LeafSignError('not_registered') + return host +} + +export interface LeafSignerDeps { + readonly hosts: HostStore + readonly signer: CaSigner + readonly caChainDer: readonly Uint8Array[] + readonly leafTtlSec?: number +} + +/** + * DEV/legacy signer — emits a signed JSON placeholder (NOT real X.509). Retained so tests and dev + * boots without configured CA material still exercise the gate/reject path. Production uses + * `createRealLeafSigner` (`ca/issue.ts`). + */ export function createLeafSigner(deps: LeafSignerDeps): LeafSigner { const ttl = deps.leafTtlSec ?? DEFAULT_LEAF_TTL_SEC return { async signHostLeaf(hostId, agentPubkey, csr) { - // 1. proof-of-possession (independent of registry state) - const pop = verifyCsrPoP(csr) - if (!pop.ok) throw new LeafSignError('csr_rejected') - // 2. no substitution: CSR pubkey must equal the presented agentPubkey - if (!timingSafeEqualBytes(pop.embeddedPub, agentPubkey)) throw new LeafSignError('csr_rejected') - // 3. registry gate: host bound, active, non-revoked, pubkey matches (INV14) - const host = await deps.hosts.get(hostId) - if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered') - if (!timingSafeEqualBytes(host.agentPubkey, agentPubkey)) throw new LeafSignError('not_registered') + const host = await assertLeafGate(deps.hosts, hostId, agentPubkey, csr) - // Only now do we invoke KMS sign() over the to-be-signed leaf. + // Only now do we invoke KMS sign() over the to-be-signed placeholder. const notAfter = Math.floor(Date.now() / 1000) + ttl const tbs = new TextEncoder().encode( JSON.stringify({ v: 1, - hostId, + hostId: host.hostId, subjectSpki: bytesToBase64(agentPubkey), // subject pubkey == agentPubkey (assertable in tests) notAfter, }), diff --git a/control-plane/src/env.ts b/control-plane/src/env.ts index 5b75a38..29f660e 100644 --- a/control-plane/src/env.ts +++ b/control-plane/src/env.ts @@ -8,6 +8,7 @@ * live KMS call; `loadEnv` validates only the presence/shape of the ref here. */ import { z } from 'zod' +import { dirname, join } from 'node:path' import { base64ToBytes } from './util/bytes.js' /** Pairing-code TTL default: 10 minutes (§5 T7). */ @@ -24,6 +25,16 @@ export interface ControlPlaneEnv { readonly caIntermediateKmsKeyRef: string /** Intermediate cert (public) + chain up to the offline root. */ readonly caIntermediateCertPath: string + /** + * Intermediate Ed25519 PRIVATE key (PKCS#8 PEM) used by the REAL leaf issuer. Optional: when the + * file is present the control-plane issues real X.509 leaves; when absent it falls back to the + * dev placeholder signer. Defaults to `caIntermediateCertPath` with `.cert.pem`/`.pem` → `.key.pem`. + */ + readonly caIntermediateKeyPath: string + /** Self-signed root cert (PEM) for the agent CA bundle. Defaults to a sibling `root.cert.pem`. */ + readonly caRootCertPath: string + /** Bare trust domain for the SPIFFE SAN (`spiffe://relay./...`). */ + readonly relayTrustDomain: string /** CA bundle used to VERIFY relay-node mTLS client certs (T9 node-auth). */ readonly nodeMtlsTrustBundlePath: string /** 'term.' for subdomain assembly. */ @@ -45,12 +56,22 @@ const intWithDefault = (fallback: number) => const requiredString = (name: string) => z.string({ required_error: `${name} is required` }).trim().min(1, `${name} must not be empty`) +/** Default intermediate KEY path from its CERT path: `*.cert.pem`/`*.pem` → `*.key.pem`. */ +function deriveKeyPath(certPath: string): string { + if (certPath.endsWith('.cert.pem')) return certPath.slice(0, -'.cert.pem'.length) + '.key.pem' + if (certPath.endsWith('.pem')) return certPath.slice(0, -'.pem'.length) + '.key.pem' + return certPath + '.key.pem' +} + const EnvSchema = z.object({ PG_URL: requiredString('PG_URL').url('PG_URL must be a valid connection URL'), REDIS_URL: requiredString('REDIS_URL').url('REDIS_URL must be a valid connection URL'), CAPABILITY_SIGN_PUBKEY_B64: requiredString('CAPABILITY_SIGN_PUBKEY_B64'), CA_INTERMEDIATE_KMS_KEY_REF: requiredString('CA_INTERMEDIATE_KMS_KEY_REF'), CA_INTERMEDIATE_CERT_PATH: requiredString('CA_INTERMEDIATE_CERT_PATH'), + CA_INTERMEDIATE_KEY_PATH: z.string().trim().optional(), + CA_ROOT_CERT_PATH: z.string().trim().optional(), + RELAY_TRUST_DOMAIN: z.string().trim().optional(), NODE_MTLS_TRUST_BUNDLE_PATH: requiredString('NODE_MTLS_TRUST_BUNDLE_PATH'), BASE_DOMAIN: requiredString('BASE_DOMAIN'), HEARTBEAT_TTL_SEC: intWithDefault(15), @@ -81,12 +102,24 @@ export function loadEnv(source: NodeJS.ProcessEnv): ControlPlaneEnv { if (capabilitySignPubkey.length !== 32) { throw new Error('Invalid control-plane env: CAPABILITY_SIGN_PUBKEY_B64 must decode to 32 bytes (Ed25519)') } + const caIntermediateKeyPath = + e.CA_INTERMEDIATE_KEY_PATH !== undefined && e.CA_INTERMEDIATE_KEY_PATH.length > 0 + ? e.CA_INTERMEDIATE_KEY_PATH + : deriveKeyPath(e.CA_INTERMEDIATE_CERT_PATH) + const caRootCertPath = + e.CA_ROOT_CERT_PATH !== undefined && e.CA_ROOT_CERT_PATH.length > 0 + ? e.CA_ROOT_CERT_PATH + : join(dirname(e.CA_INTERMEDIATE_CERT_PATH), 'root.cert.pem') return { pgUrl: e.PG_URL, redisUrl: e.REDIS_URL, capabilitySignPubkey, caIntermediateKmsKeyRef: e.CA_INTERMEDIATE_KMS_KEY_REF, caIntermediateCertPath: e.CA_INTERMEDIATE_CERT_PATH, + caIntermediateKeyPath, + caRootCertPath, + relayTrustDomain: + e.RELAY_TRUST_DOMAIN !== undefined && e.RELAY_TRUST_DOMAIN.length > 0 ? e.RELAY_TRUST_DOMAIN : 'example.com', nodeMtlsTrustBundlePath: e.NODE_MTLS_TRUST_BUNDLE_PATH, baseDomain: e.BASE_DOMAIN, heartbeatTtlSec: e.HEARTBEAT_TTL_SEC, diff --git a/control-plane/src/main.ts b/control-plane/src/main.ts index 8c40c48..2da8bd5 100644 --- a/control-plane/src/main.ts +++ b/control-plane/src/main.ts @@ -12,9 +12,10 @@ * Ed25519 signer (DEV ONLY — NOT a real KMS). */ import Fastify, { type FastifyInstance } from 'fastify' +import { existsSync, readFileSync } from 'node:fs' import type { ControlPlaneEnv } from './env.js' import { createMemoryStores } from './store/memory.js' -import type { Stores } from './store/ports.js' +import type { Stores, HostStore } from './store/ports.js' import { createAuditLog } from './audit/log.js' import { createAccountRegistry } from './registry/accounts.js' import { createHostRegistry } from './registry/hosts.js' @@ -22,14 +23,15 @@ import { createSessionRegistry } from './registry/sessions.js' import { createSubdomainAssigner } from './subdomain/assign.js' import { createPairingIssuer } from './pairing/issue.js' import { createPairingRedeemer } from './pairing/redeem.js' -import { createLeafSigner } from './ca/sign.js' +import { createLeafSigner, type LeafSigner } from './ca/sign.js' +import { loadRealLeafSigner } from './ca/issue.js' import { createRoutingTable } from './routing/table.js' import { createInMemoryRevocationBus, type TestableRevocationBus } from './routing/bus.js' import { createMeteringCollector } from './metering/collect.js' import { createDeprovisioner } from './deprovision/deprovision.js' import { createAuthorizer, type CapabilityVerifier } from './api/authz.js' import { buildRouter } from './api/provision.js' -import { buildCaSigner, inProcessCaSigner, type KmsResolver } from './boot/ca-wiring.js' +import { buildCaSigner, inProcessCaSigner, type KmsResolver, type CaSigner } from './boot/ca-wiring.js' import { configureCapabilityVerifyKey } from './boot/verifier.js' import type { RevocationBus } from 'relay-contracts' @@ -52,6 +54,36 @@ const refuseAllVerifier: CapabilityVerifier = { }, } +/** + * Choose the leaf signer. When the intermediate PRIVATE key file is present on disk, issue REAL + * X.509 leaves (production); when absent, fall back to the dev placeholder signer so tests and + * key-less dev boots still run. A present-but-unreadable/malformed key FAILS FAST (INV9). + */ +async function buildLeafSigner( + env: ControlPlaneEnv, + hosts: HostStore, + caSigner: CaSigner, + caChainDer: readonly Uint8Array[], +): Promise { + if (!existsSync(env.caIntermediateKeyPath)) { + return createLeafSigner({ hosts, signer: caSigner, caChainDer }) + } + try { + return await loadRealLeafSigner({ + hosts, + intermediateKeyPem: readFileSync(env.caIntermediateKeyPath, 'utf8'), + intermediateCertPem: readFileSync(env.caIntermediateCertPath, 'utf8'), + rootCertPem: readFileSync(env.caRootCertPath, 'utf8'), + trustDomain: env.relayTrustDomain, + }) + } catch (err: unknown) { + // Never echo key material — only that loading failed and which path shape was configured (INV9). + throw new Error( + `failed to load CA leaf-signing material: ${err instanceof Error ? err.message : 'unknown'}`, + ) + } +} + /** In-process KMS resolver — DEV ONLY. Production injects a real non-exportable KMS key (§3.1). */ function devKmsResolver(): KmsResolver { const signer = inProcessCaSigner() @@ -77,7 +109,7 @@ export async function buildControlPlane( const subdomains = createSubdomainAssigner({ subdomains: stores.subdomains, audit }) const caSigner = await buildCaSigner(env, overrides.kmsResolver ?? devKmsResolver()) - const leafSigner = createLeafSigner({ hosts: stores.hosts, signer: caSigner, caChainDer }) + const leafSigner = await buildLeafSigner(env, stores.hosts, caSigner, caChainDer) const pairingIssuer = createPairingIssuer({ pairing: stores.pairing, pairingTtlSec: env.pairingTtlSec, audit }) const redeemer = createPairingRedeemer({ diff --git a/control-plane/src/pairing/redeem.ts b/control-plane/src/pairing/redeem.ts index fe8957f..c81b379 100644 --- a/control-plane/src/pairing/redeem.ts +++ b/control-plane/src/pairing/redeem.ts @@ -89,7 +89,7 @@ export function createPairingRedeemer(deps: RedeemDeps): PairingRedeemer { if (row.record.redeemedAt !== null) throw new RedeemError('already_redeemed') // CSR proof-of-possession + no-substitution. A failure counts toward the code-scoped lockout. - const pop = verifyCsrPoP(input.csr) + const pop = await verifyCsrPoP(input.csr) if (!pop.ok || !timingSafeEqualBytes(pop.embeddedPub, input.agentPubkey)) { await deps.pairing.registerFailure(codeHash) throw new RedeemError('bad_csr') diff --git a/control-plane/test/ca.test.ts b/control-plane/test/ca.test.ts index 14b6f16..c6566ff 100644 --- a/control-plane/test/ca.test.ts +++ b/control-plane/test/ca.test.ts @@ -35,7 +35,7 @@ describe('T8 signHostLeaf — INV14 registry-gated + CSR PoP', () => { const forged = new Uint8Array(96) forged.set(publicKeyRaw, 0) forged.set(randomBytes(64), 32) - expect(verifyCsrPoP(forged).ok).toBe(false) + expect((await verifyCsrPoP(forged)).ok).toBe(false) await expect(signer.signHostLeaf(host.hostId, publicKeyRaw, forged)).rejects.toBeInstanceOf(LeafSignError) expect(signSpy).not.toHaveBeenCalled() }) diff --git a/control-plane/test/interop.test.ts b/control-plane/test/interop.test.ts new file mode 100644 index 0000000..8a681d0 --- /dev/null +++ b/control-plane/test/interop.test.ts @@ -0,0 +1,220 @@ +/** + * OBJECTIVE ORACLE (INV4/INV14) — proves the control-plane's REAL leaf issuer emits an X.509 leaf + * that relay-auth's `verifyAgentCert` accepts, WITHOUT deploying either service. + * + * A throwaway Ed25519 root→intermediate chain is generated in-test; a leaf is issued through + * `createRealLeafSigner` for a random account/host + agent pubkey; then relay-auth verifies the + * leaf against the intermediate+root bundle with a fake host registry. Positive path asserts + * `{ ok: true, hostId, accountId }`; negatives assert the exact reject reasons. + */ +import 'reflect-metadata' +import { describe, test, expect } from 'vitest' +import * as x509 from '@peculiar/x509' +import { webcrypto, randomUUID } from 'node:crypto' +import { verifyAgentCert, defaultParseX509 } from 'relay-auth/src/agent/verify-mtls.js' +import { spiffeIdFor } from 'relay-auth/src/agent/spiffe.js' +import type { HostRecord } from 'relay-contracts' +import { createMemoryStores } from '../src/store/memory.js' +import { createHostRegistry } from '../src/registry/hosts.js' +import { createRealLeafSigner, loadRealLeafSigner } from '../src/ca/issue.js' +import { buildCsr } from '../src/ca/csr.js' +import { fingerprint } from '../src/ca/fingerprint.js' +import { generateEd25519 } from '../src/util/crypto.js' + +x509.cryptoProvider.set(webcrypto) + +const TRUST_DOMAIN = 'example.com' +const DAY_MS = 24 * 60 * 60 * 1000 + +interface Ca { + readonly intKey: CryptoKey + readonly issuerName: x509.Name + readonly caChainDer: readonly Uint8Array[] + readonly caChainPem: string + readonly intermediateKeyPem: string + readonly intermediateCertPem: string + readonly rootCertPem: string +} + +type KeyPair = { readonly publicKey: CryptoKey; readonly privateKey: CryptoKey } + +function derToPemLabeled(der: Uint8Array, label: string): string { + const b64 = Buffer.from(der).toString('base64').match(/.{1,64}/g)?.join('\n') ?? '' + return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----\n` +} + +async function makeCa(): Promise { + const subtle = webcrypto.subtle + const rootKeys = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as KeyPair + const intKeys = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as KeyPair + const now = Date.now() + const rootCert = await x509.X509CertificateGenerator.createSelfSigned({ + serialNumber: '01', + name: 'CN=relay-root', + notBefore: new Date(now - DAY_MS), + notAfter: new Date(now + DAY_MS), + keys: rootKeys, + signingAlgorithm: { name: 'Ed25519' }, + extensions: [new x509.BasicConstraintsExtension(true, undefined, true)], + }) + const intCert = await x509.X509CertificateGenerator.create({ + serialNumber: '02', + subject: 'CN=relay-intermediate', + issuer: rootCert.subjectName, + notBefore: new Date(now - DAY_MS), + notAfter: new Date(now + DAY_MS), + publicKey: intKeys.publicKey, + signingKey: rootKeys.privateKey, + signingAlgorithm: { name: 'Ed25519' }, + extensions: [new x509.BasicConstraintsExtension(true, undefined, true)], + }) + const intKeyPkcs8 = new Uint8Array(await subtle.exportKey('pkcs8', intKeys.privateKey)) + return { + intKey: intKeys.privateKey, + issuerName: intCert.subjectName, + caChainDer: [new Uint8Array(intCert.rawData), new Uint8Array(rootCert.rawData)], + caChainPem: `${intCert.toString('pem')}\n${rootCert.toString('pem')}`, + intermediateKeyPem: derToPemLabeled(intKeyPkcs8, 'PRIVATE KEY'), + intermediateCertPem: intCert.toString('pem'), + rootCertPem: rootCert.toString('pem'), + } +} + +function derToPem(der: Uint8Array): string { + return new x509.X509Certificate(der).toString('pem') +} + +/** Minimal relay-contracts HostRecord for the fake registry (only accountId/status are read). */ +function fakeHost(hostId: string, accountId: string, status: HostRecord['status']): HostRecord { + return { + hostId, + accountId, + subdomain: 'host', + agentPubkey: new Uint8Array(32), + enrollFpr: 'fpr', + status, + lastSeen: new Date().toISOString(), + createdAt: new Date().toISOString(), + revokedAt: status === 'revoked' ? new Date().toISOString() : null, + } as HostRecord +} + +async function issueLeaf() { + const ca = await makeCa() + const stores = createMemoryStores() + const hosts = createHostRegistry({ hosts: stores.hosts }) + const accountId = randomUUID() + const { publicKeyRaw, privateKey } = generateEd25519() + const host = await hosts.bindHost({ + accountId, + subdomain: 'alice', + agentPubkey: publicKeyRaw, + enrollFpr: fingerprint(publicKeyRaw), + }) + const signer = createRealLeafSigner({ + hosts: stores.hosts, + intermediateKey: ca.intKey, + issuerName: ca.issuerName, + caChainDer: ca.caChainDer, + trustDomain: TRUST_DOMAIN, + }) + const { cert, caChain } = await signer.signHostLeaf(host.hostId, publicKeyRaw, buildCsr(privateKey, publicKeyRaw)) + return { ca, accountId, hostId: host.hostId, publicKeyRaw, leafPem: derToPem(cert), caChain } +} + +describe('interop: control-plane real leaf ⇄ relay-auth verifyAgentCert', () => { + test('valid leaf for an active enrolled host → { ok: true, hostId, accountId }', async () => { + const { ca, accountId, hostId, leafPem, caChain } = await issueLeaf() + expect(caChain).toHaveLength(2) // intermediate + root DER + const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'online') : null) } + const res = await verifyAgentCert(leafPem, ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509) + expect(res).toEqual({ ok: true, hostId, accountId }) + }) + + test('SPIFFE accountId ≠ registry accountId → spiffe_account_mismatch', async () => { + const { ca, hostId, leafPem } = await issueLeaf() + const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, randomUUID(), 'online') : null) } + const res = await verifyAgentCert(leafPem, ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509) + expect(res.ok).toBe(false) + expect(res.reason).toBe('spiffe_account_mismatch') + }) + + test('revoked host → host_revoked', async () => { + const { ca, accountId, hostId, leafPem } = await issueLeaf() + const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'revoked') : null) } + const res = await verifyAgentCert(leafPem, ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509) + expect(res.ok).toBe(false) + expect(res.reason).toBe('host_revoked') + }) + + test('expired leaf → expired', async () => { + const ca = await makeCa() + const accountId = randomUUID() + const hostId = randomUUID() + const { publicKeyRaw } = generateEd25519() + const spiffe = spiffeIdFor(accountId, hostId, TRUST_DOMAIN) + const prefix = Uint8Array.from([0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00]) + const spki = new Uint8Array(44) + spki.set(prefix, 0) + spki.set(publicKeyRaw, 12) + const subjectKey = await webcrypto.subtle.importKey('spki', spki, { name: 'Ed25519' }, true, ['verify']) + const now = Date.now() + const expiredLeaf = await x509.X509CertificateGenerator.create({ + serialNumber: '0e', + subject: `CN=${hostId}`, + issuer: ca.issuerName, + notBefore: new Date(now - 2 * DAY_MS), + notAfter: new Date(now - DAY_MS), // already expired + publicKey: subjectKey, + signingKey: ca.intKey, + signingAlgorithm: { name: 'Ed25519' }, + extensions: [new x509.SubjectAlternativeNameExtension([{ type: 'url', value: spiffe }])], + }) + const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'online') : null) } + const res = await verifyAgentCert( + expiredLeaf.toString('pem'), + ca.caChainPem, + Math.floor(Date.now() / 1000), + registry, + defaultParseX509, + ) + expect(res.ok).toBe(false) + expect(res.reason).toBe('expired') + }) + + test('boot path: loadRealLeafSigner (PEM material) issues a verifiable leaf', async () => { + const ca = await makeCa() + const stores = createMemoryStores() + const hosts = createHostRegistry({ hosts: stores.hosts }) + const accountId = randomUUID() + const { publicKeyRaw, privateKey } = generateEd25519() + const host = await hosts.bindHost({ + accountId, + subdomain: 'boot', + agentPubkey: publicKeyRaw, + enrollFpr: fingerprint(publicKeyRaw), + }) + const signer = await loadRealLeafSigner({ + hosts: stores.hosts, + intermediateKeyPem: ca.intermediateKeyPem, + intermediateCertPem: ca.intermediateCertPem, + rootCertPem: ca.rootCertPem, + trustDomain: TRUST_DOMAIN, + }) + const { cert } = await signer.signHostLeaf(host.hostId, publicKeyRaw, buildCsr(privateKey, publicKeyRaw)) + const registry = { + getById: async (id: string) => (id === host.hostId ? fakeHost(host.hostId, accountId, 'online') : null), + } + const res = await verifyAgentCert(derToPem(cert), ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509) + expect(res).toEqual({ ok: true, hostId: host.hostId, accountId }) + }) + + test('intermediate-only bundle (no root) → chain_invalid', async () => { + const { ca, accountId, hostId, leafPem } = await issueLeaf() + const intermediateOnly = ca.caChainPem.split('-----END CERTIFICATE-----')[0]! + '-----END CERTIFICATE-----\n' + const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'online') : null) } + const res = await verifyAgentCert(leafPem, intermediateOnly, Math.floor(Date.now() / 1000), registry, defaultParseX509) + expect(res.ok).toBe(false) + expect(res.reason).toBe('chain_invalid') + }) +})