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:
120
control-plane/src/ca/csr-ec.ts
Normal file
120
control-plane/src/ca/csr-ec.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* A1 — ECDSA-P256 PKCS#10 parse + proof-of-possession, mirroring `ca/csr.ts` (Ed25519 relay path).
|
||||
*
|
||||
* Hardware-bound host/device keys are P-256 ONLY (Secure Enclave / Android Keystore). This module
|
||||
* parses a P-256 PKCS#10 `CertificationRequest`, verifies its self-signature (PoP — the requester
|
||||
* holds the private key for the embedded pubkey; `@peculiar`'s `req.verify()` handles ECDSA), and
|
||||
* exposes the embedded public key as SubjectPublicKeyInfo DER so the issuer can gate on it.
|
||||
*
|
||||
* FAIL-CLOSED and UNIFORM: malformed DER, a non-P256 key, or a bad signature ALL return
|
||||
* `{ ok: false, embeddedPubSpki: [] }` with no distinguishing detail. Independent of registry state.
|
||||
*
|
||||
* `reflect-metadata` must load before `@peculiar/x509` (tsyringe polyfill) — keep it first.
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { AsnConvert } from '@peculiar/asn1-schema'
|
||||
import { SubjectPublicKeyInfo } from '@peculiar/asn1-x509'
|
||||
import { webcrypto } from 'node:crypto'
|
||||
import { timingSafeEqualBytes } from '../util/bytes.js'
|
||||
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
/** OID 1.2.840.10045.2.1 (id-ecPublicKey). */
|
||||
const OID_EC_PUBLIC_KEY = '1.2.840.10045.2.1'
|
||||
/** DER of the namedCurve parameter OID prime256v1 / secp256r1 (1.2.840.10045.3.1.7). */
|
||||
const PRIME256V1_PARAM_DER = Uint8Array.from([
|
||||
0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07,
|
||||
])
|
||||
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||||
}
|
||||
|
||||
/** True iff `spkiDer` is a well-formed EC P-256 SubjectPublicKeyInfo. Never throws. */
|
||||
function isP256Spki(spkiDer: Uint8Array): boolean {
|
||||
try {
|
||||
const spki = AsnConvert.parse(toArrayBuffer(spkiDer), SubjectPublicKeyInfo)
|
||||
if (spki.algorithm.algorithm !== OID_EC_PUBLIC_KEY) return false
|
||||
const params = spki.algorithm.parameters
|
||||
if (!params) return false
|
||||
// Reuse the shared constant-time comparison rather than a local short-circuiting one.
|
||||
return timingSafeEqualBytes(new Uint8Array(params), PRIME256V1_PARAM_DER)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a FRESH uniform-failure result per call — never share a module-level singleton (immutability):
|
||||
* callers `toEqual`-assert this shape, and a shared instance would let one caller mutate a value another
|
||||
* caller later reads.
|
||||
*/
|
||||
function uniformFailure(): { ok: false; embeddedPubSpki: Uint8Array } {
|
||||
return { ok: false, embeddedPubSpki: new Uint8Array(0) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an ECDSA-P256 CSR's proof-of-possession. Parses the PKCS#10 DER, requires an EC P-256
|
||||
* embedded key, and checks the self-signature. Returns the embedded pubkey as SPKI DER on success.
|
||||
* FAIL-CLOSED and UNIFORM: any malformed input, non-P256 key, or bad signature yields
|
||||
* `{ ok: false, embeddedPubSpki: [] }`. Independent of any registry state.
|
||||
*/
|
||||
export async function verifyCsrPoPEc(
|
||||
csr: Uint8Array,
|
||||
): Promise<{ ok: boolean; embeddedPubSpki: Uint8Array }> {
|
||||
try {
|
||||
const req = new x509.Pkcs10CertificateRequest(toArrayBuffer(csr))
|
||||
const spkiDer = new Uint8Array(req.publicKey.rawData)
|
||||
if (!isP256Spki(spkiDer)) return uniformFailure()
|
||||
const ok = await req.verify()
|
||||
return ok ? { ok: true, embeddedPubSpki: spkiDer } : uniformFailure()
|
||||
} catch {
|
||||
return uniformFailure()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap routing predicate for the `/enroll` fork: true iff `csr` parses as a PKCS#10 whose embedded
|
||||
* key is EC P-256. Inspects ONLY the SubjectPublicKeyInfo algorithm — it does NOT verify the
|
||||
* self-signature (the native arm's `verifyCsrPoPEc` gate does that after routing). Never throws:
|
||||
* a malformed CSR or any non-P256 key (e.g. Ed25519 relay CSRs) yields `false`, so the caller falls
|
||||
* through to the Ed25519 relay arm.
|
||||
*/
|
||||
export function isEcP256Csr(csr: Uint8Array): boolean {
|
||||
try {
|
||||
const req = new x509.Pkcs10CertificateRequest(toArrayBuffer(csr))
|
||||
return isP256Spki(new Uint8Array(req.publicKey.rawData))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** A WebCrypto key pair (the `CryptoKeyPair` global is not in this project's TS lib set). */
|
||||
type WebCryptoKeyPair = { readonly publicKey: CryptoKey; readonly privateKey: CryptoKey }
|
||||
|
||||
export interface BuildCsrEcResult {
|
||||
/** The PKCS#10 CertificationRequest DER. */
|
||||
readonly der: Uint8Array
|
||||
/** The generated P-256 key pair (extractable — TEST ONLY). */
|
||||
readonly keys: WebCryptoKeyPair
|
||||
}
|
||||
|
||||
/**
|
||||
* TEST HELPER — build a real ECDSA-P256 PKCS#10 CSR self-signed by a freshly generated P-256 key, so
|
||||
* tests exercise the real parse/verify path (production CSRs come from hardware unchanged). Mirrors
|
||||
* `ca/csr.ts` `buildCsr`. Returns the DER and the generated key pair.
|
||||
*/
|
||||
export async function buildCsrEc(subject = 'CN=web-terminal-device'): Promise<BuildCsrEcResult> {
|
||||
const keys = (await webcrypto.subtle.generateKey(
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
true,
|
||||
['sign', 'verify'],
|
||||
)) as unknown as WebCryptoKeyPair
|
||||
const csr = await x509.Pkcs10CertificateRequestGenerator.create({
|
||||
name: subject,
|
||||
keys,
|
||||
signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' },
|
||||
})
|
||||
return { der: new Uint8Array(csr.rawData), keys }
|
||||
}
|
||||
137
control-plane/src/ca/device-issue.ts
Normal file
137
control-plane/src/ca/device-issue.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* A4 (FIX C-2) — P-256 DEVICE leaf issuance off the device-CA, via the single `assembleCertificate`
|
||||
* primitive (KMS-signed TBS). This is the data-path identity the app presents on the existing mTLS
|
||||
* path; it is a SEPARATE trust root from the Ed25519 relay agent-CA and the P-256 frp-client-CA
|
||||
* (§1.3). Modeled on `ca/issue.ts` (`createRealLeafSigner`) but:
|
||||
* - subject key is EC P-256 (Secure Enclave / Android Keystore are P-256 only);
|
||||
* - the CA signs via `CaSigner.sign()` (device-CA hot signer, custody behind KMS — §5), sigAlg
|
||||
* `ecdsa-p256`;
|
||||
* - SAN = dNSName `<sub>.<baseDomain>` (the nginx :8470 enforcement key, FIX H-2) + URI
|
||||
* `spiffe://relay.<trustDomain>/account/<a>/device/<d>` (identity/audit), built with relay-auth's
|
||||
* OWN `spiffeIdFor(..., 'device')` so the SAN can never drift from the verifier's parser.
|
||||
*
|
||||
* Registry-gated like `assertLeafGate` (INV14): CSR proof-of-possession + device bound/active/
|
||||
* non-revoked + CSR-key == registered-key. Any failure rejects UNIFORMLY (`DeviceLeafSignError`),
|
||||
* never leaking which check failed; issuance is unreachable when any check fails. Serial + validity
|
||||
* are read from the gated record (the record is authoritative for expiry/CRL pairing).
|
||||
*
|
||||
* `reflect-metadata` must load before `@peculiar/x509` (tsyringe polyfill) — keep it first.
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { webcrypto } from 'node:crypto'
|
||||
import { spiffeIdFor } from 'relay-auth/src/agent/spiffe.js'
|
||||
import type { CaSigner } from '../boot/ca-wiring.js'
|
||||
import type { DeviceStore } from '../store/ports.js'
|
||||
import type { DeviceRecord } from '../registry/devices.js'
|
||||
import { assembleCertificate } from './x509-assembler.js'
|
||||
import { verifyCsrPoPEc } from './csr-ec.js'
|
||||
import { timingSafeEqualBytes } from '../util/bytes.js'
|
||||
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
/** Backdate notBefore to tolerate small clock skew between control-plane and the mTLS terminator. */
|
||||
const CLOCK_SKEW_SEC = 60
|
||||
|
||||
/** Uniform device-leaf gate reject — never leaks which check failed (mirrors `LeafSignError`). */
|
||||
export class DeviceLeafSignError extends Error {
|
||||
constructor(public readonly code: 'csr_rejected' | 'not_registered') {
|
||||
super('device leaf signing refused') // uniform message
|
||||
this.name = 'DeviceLeafSignError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeviceLeafResult {
|
||||
readonly cert: Uint8Array
|
||||
readonly caChain: readonly Uint8Array[]
|
||||
readonly serial: string
|
||||
readonly notBefore: Date
|
||||
readonly notAfter: Date
|
||||
}
|
||||
|
||||
export interface DeviceLeafSigner {
|
||||
/** Gate on the device registry, then issue the P-256 leaf for the gated record. */
|
||||
signDeviceLeaf(deviceId: string, csr: Uint8Array): Promise<DeviceLeafResult>
|
||||
}
|
||||
|
||||
export interface DeviceLeafSignerDeps {
|
||||
/** Device-CA P-256 signer (KMS-shaped; `inProcessP256CaSigner` in dev/tests). */
|
||||
readonly signer: CaSigner
|
||||
/** Device-CA subject as an `x509.Name`/DN string — used verbatim as the leaf issuer. */
|
||||
readonly issuer: x509.Name | string
|
||||
/** DER of [device-CA, root] returned to the app as its CA bundle. */
|
||||
readonly caChainDer: readonly Uint8Array[]
|
||||
/** Base domain for the dNSName SAN: `<sub>.<sanBaseDomain>` (e.g. `terminal.yaojia.wang`). */
|
||||
readonly sanBaseDomain: string
|
||||
/** Bare trust domain; the SPIFFE builder prepends `relay.`. */
|
||||
readonly trustDomain: string
|
||||
/** Device registry (gate source of truth). */
|
||||
readonly devices: DeviceStore
|
||||
}
|
||||
|
||||
/** Parse a hex serial into big-endian bytes for the certificate serialNumber. */
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
return new Uint8Array(Buffer.from(hex, 'hex'))
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry + proof-of-possession gate for a device leaf (INV14). Ordered checks, SAME reject:
|
||||
* 1. CSR PoP — valid P-256 PKCS#10 self-signature over its embedded key.
|
||||
* 2. device bound + active (non-revoked).
|
||||
* 3. CSR-embedded key == the device's REGISTERED public key (no substitution).
|
||||
* Returns the gated record + the embedded SPKI so the issuer builds the SAN from authoritative data.
|
||||
*/
|
||||
export async function assertDeviceLeafGate(
|
||||
devices: DeviceStore,
|
||||
deviceId: string,
|
||||
csr: Uint8Array,
|
||||
): Promise<{ record: DeviceRecord; embeddedPubSpki: Uint8Array }> {
|
||||
const pop = await verifyCsrPoPEc(csr)
|
||||
if (!pop.ok) throw new DeviceLeafSignError('csr_rejected')
|
||||
const record = await devices.get(deviceId)
|
||||
if (record === null || record.status === 'revoked') throw new DeviceLeafSignError('not_registered')
|
||||
if (!timingSafeEqualBytes(record.ecPubkeySpki, pop.embeddedPubSpki)) {
|
||||
throw new DeviceLeafSignError('not_registered')
|
||||
}
|
||||
return { record, embeddedPubSpki: pop.embeddedPubSpki }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the device-leaf signer. Each leaf: X.509 v3, EC P-256 subject = the CSR-embedded key,
|
||||
* SAN dNSName `<sub>.<baseDomain>` + device SPIFFE URI, CA:false, KeyUsage digitalSignature, EKU
|
||||
* clientAuth, validity [now-skew, record.notAfter], serial = record.serial, signed by the device-CA.
|
||||
*/
|
||||
export function createDeviceLeafSigner(deps: DeviceLeafSignerDeps): DeviceLeafSigner {
|
||||
return {
|
||||
async signDeviceLeaf(deviceId, csr) {
|
||||
const { record, embeddedPubSpki } = await assertDeviceLeafGate(deps.devices, deviceId, csr)
|
||||
|
||||
const dnsName = `${record.subdomainScope}.${deps.sanBaseDomain}`
|
||||
const spiffe = spiffeIdFor(record.accountId, record.deviceId, deps.trustDomain, 'device')
|
||||
const notBefore = new Date(Date.now() - CLOCK_SKEW_SEC * 1000)
|
||||
const notAfter = new Date(record.notAfter)
|
||||
|
||||
const cert = await assembleCertificate({
|
||||
subjectPublicKey: embeddedPubSpki, // SPKI DER of the hardware-bound P-256 key
|
||||
subject: `CN=${record.deviceId}`,
|
||||
issuer: deps.issuer,
|
||||
serialNumber: hexToBytes(record.serial),
|
||||
notBefore,
|
||||
notAfter,
|
||||
extensions: [
|
||||
new x509.SubjectAlternativeNameExtension([
|
||||
{ type: 'dns', value: dnsName },
|
||||
{ type: 'url', value: spiffe },
|
||||
]),
|
||||
new x509.BasicConstraintsExtension(false, undefined, true),
|
||||
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
|
||||
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.clientAuth]),
|
||||
],
|
||||
signer: deps.signer,
|
||||
sigAlg: 'ecdsa-p256',
|
||||
})
|
||||
|
||||
return { cert, caChain: deps.caChainDer, serial: record.serial, notBefore, notAfter }
|
||||
},
|
||||
}
|
||||
}
|
||||
117
control-plane/src/ca/frpclient-issue.ts
Normal file
117
control-plane/src/ca/frpclient-issue.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* B1 / FIX H-host-2, H-host-4 — the P-256 HOST frp-client leaf signer.
|
||||
*
|
||||
* The `frp-client-CA` is P-256 (matching the VPS `gen-device-ca.sh` "P-256 never Ed25519" and the
|
||||
* frps Go-TLS client-cert requirement), so the host key, its CSR, and this leaf are ALL P-256 —
|
||||
* distinct from the Ed25519 relay agent-CA. This module mirrors `ca/issue.ts`'s
|
||||
* `createRealLeafSigner` SHAPE (registry-gate → build extensions → sign → return leaf + CA chain)
|
||||
* but:
|
||||
* (a) verifies the P-256 CSR via `verifyCsrPoPEc` (NOT the Ed25519 `verifyCsrPoP`);
|
||||
* (b) gates on the host registry (bound, active, non-revoked, embedded pubkey == registered
|
||||
* pubkey — the pubkey is an EC SubjectPublicKeyInfo DER here, not a raw-32 Ed25519 key);
|
||||
* (c) issues via `assembleCertificate` (A1) with `sigAlg:'ecdsa-p256'` and the frp-client-CA
|
||||
* `CaSigner` — raw CA key never loaded (INV9).
|
||||
*
|
||||
* SAN grammar (ONE grammar, FIX H-host-4): `dNSName <sub>.<dnsZone>` is the ENFORCEMENT key the
|
||||
* A3 nginx njs parses; the `URI` SPIFFE-ID (`.../host/<sub>`, built with relay-auth's own builder so
|
||||
* it can never drift from the verifier's parser) is identity/audit. EKU clientAuth, CA:false,
|
||||
* KeyUsage digitalSignature; validity `[now-CLOCK_SKEW, now+ttl]`.
|
||||
*
|
||||
* `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 type { HostRecord } from '../model/records.js'
|
||||
import type { CaSigner } from '../boot/ca-wiring.js'
|
||||
import { assembleCertificate } from './x509-assembler.js'
|
||||
import { verifyCsrPoPEc } from './csr-ec.js'
|
||||
import { LeafSignError, DEFAULT_LEAF_TTL_SEC, type LeafSigner } from './sign.js'
|
||||
import { timingSafeEqualBytes } from '../util/bytes.js'
|
||||
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
/** Backdate notBefore to tolerate small clock skew between control-plane, host, frps and nginx. */
|
||||
const CLOCK_SKEW_SEC = 60
|
||||
/** DNS zone the reachable subdomain lives under; the leftmost label is the nginx enforcement key. */
|
||||
const DEFAULT_DNS_ZONE = 'terminal.yaojia.wang'
|
||||
|
||||
export interface FrpClientLeafSignerDeps {
|
||||
readonly hosts: HostStore
|
||||
/** frp-client-CA P-256 signer (KMS-shaped; `inProcessP256CaSigner` in tests). Raw key never loaded. */
|
||||
readonly signer: CaSigner
|
||||
/** frp-client-CA subject as a `Name` — used verbatim as the leaf issuer so `checkIssued` matches. */
|
||||
readonly issuerName: x509.Name | string
|
||||
/** DER of [frp-client-CA] (+ any parents) returned to the host as its CA bundle. */
|
||||
readonly caChainDer: readonly Uint8Array[]
|
||||
/** Bare trust domain for the SPIFFE-ID; `spiffeIdFor` prepends `relay.`. */
|
||||
readonly trustDomain: string
|
||||
/** DNS zone for the enforcement `dNSName` (default `terminal.yaojia.wang`). */
|
||||
readonly dnsZone?: string
|
||||
readonly leafTtlSec?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* P-256 host-leaf gate — the ECDSA analogue of `sign.ts` `assertLeafGate`. SAME ordered checks,
|
||||
* SAME uniform `LeafSignError` reject (never leaks which check failed), issuance NEVER reached on
|
||||
* any failure:
|
||||
* 1. P-256 CSR proof-of-possession (`verifyCsrPoPEc`, independent of registry state);
|
||||
* 2. no substitution: the CSR's embedded EC SPKI == the presented `agentPubkey`;
|
||||
* 3. registry: host bound, ACTIVE (non-revoked), and its stored pubkey == the presented one.
|
||||
*/
|
||||
async function assertFrpClientLeafGate(
|
||||
hosts: HostStore,
|
||||
hostId: string,
|
||||
agentPubkey: Uint8Array,
|
||||
csr: Uint8Array,
|
||||
): Promise<HostRecord> {
|
||||
const pop = await verifyCsrPoPEc(csr)
|
||||
if (!pop.ok) throw new LeafSignError('csr_rejected')
|
||||
if (!timingSafeEqualBytes(pop.embeddedPubSpki, agentPubkey)) throw new LeafSignError('csr_rejected')
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the production HOST frp-client leaf signer. Every issued leaf: X.509 v3, EC P-256 subject =
|
||||
* the enrolled host pubkey (SPKI DER), SAN = { dNSName `<sub>.<zone>`, URI host-SPIFFE-ID }, CA:false,
|
||||
* KeyUsage digitalSignature, EKU clientAuth, validity `[now-skew, now+ttl]`, signed by the P-256
|
||||
* frp-client-CA via `assembleCertificate`. Returns leaf DER + the injected CA chain DER.
|
||||
*/
|
||||
export function createFrpClientLeafSigner(deps: FrpClientLeafSignerDeps): LeafSigner {
|
||||
const ttl = deps.leafTtlSec ?? DEFAULT_LEAF_TTL_SEC
|
||||
const dnsZone = deps.dnsZone ?? DEFAULT_DNS_ZONE
|
||||
return {
|
||||
async signHostLeaf(hostId, agentPubkey, csr) {
|
||||
const host = await assertFrpClientLeafGate(deps.hosts, hostId, agentPubkey, csr)
|
||||
const sub = host.subdomain
|
||||
const dnsName = `${sub}.${dnsZone}`
|
||||
const spiffe = spiffeIdFor(host.accountId, sub, deps.trustDomain, 'host')
|
||||
const now = Date.now()
|
||||
const cert = await assembleCertificate({
|
||||
subjectPublicKey: agentPubkey, // EC P-256 SubjectPublicKeyInfo DER
|
||||
subject: `CN=${sub}`,
|
||||
issuer: deps.issuerName,
|
||||
serialNumber: randomBytes(16),
|
||||
notBefore: new Date(now - CLOCK_SKEW_SEC * 1000),
|
||||
notAfter: new Date(now + ttl * 1000),
|
||||
extensions: [
|
||||
new x509.SubjectAlternativeNameExtension([
|
||||
{ type: 'dns', value: dnsName }, // FIX H-host-4: the enforcement key nginx njs parses
|
||||
{ type: 'url', value: spiffe }, // identity/audit — never the enforcement key
|
||||
]),
|
||||
new x509.BasicConstraintsExtension(false, undefined, true),
|
||||
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
|
||||
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.clientAuth]),
|
||||
],
|
||||
signer: deps.signer,
|
||||
sigAlg: 'ecdsa-p256',
|
||||
})
|
||||
return { cert, caChain: deps.caChainDer }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,55 +1,95 @@
|
||||
/**
|
||||
* T15 — CA leaf RENEWAL (INV14). Re-signs a short-TTL leaf for an already-bound, non-revoked host
|
||||
* under the current intermediate. SAME guards as T8 `signHostLeaf`: (1) CSR proof-of-possession
|
||||
* against the embedded pubkey; (2) embedded pubkey == the host's registered `agent_pubkey`;
|
||||
* (3) host active/non-revoked. Any failure → same reject path. A drained/revoked host cannot renew
|
||||
* (closes the INV12+INV14 loop). Signing = `CaSigner.sign()` (KMS, §3.1), never a raw key.
|
||||
* Distinct file from T8 (`ca/sign.ts`) — shares only the KMS-signer primitive.
|
||||
* A6 (FIX H-host-3) — leaf RENEWAL, UPGRADED off the DEV JSON-blob placeholder to REAL X.509.
|
||||
*
|
||||
* Renewal == re-issue a fresh short-TTL leaf for an ALREADY-bound, non-revoked identity using the
|
||||
* SAME registered key + the SAME subdomain the registry holds — NEVER client-supplied input (the
|
||||
* A4 anti-smuggling lesson). It does NOT re-implement issuance: it DELEGATES to the P-256 issuers
|
||||
* (`frpclient-issue` for the host frp-client leaf, `device-issue` for the device leaf), both of which
|
||||
* route through the single `assembleCertificate` primitive (sigAlg `ecdsa-p256`, the CA signer behind
|
||||
* KMS, INV9). Delegation keeps ONE registry-gate + ONE SAN grammar (DRY) and means the emitted cert is
|
||||
* a real, tool-parseable X.509 v3 leaf — not the retired signed-JSON placeholder.
|
||||
*
|
||||
* The gate work all lives in the delegated signers:
|
||||
* - host: `signHostLeaf(hostId, subjectPubkey, csr)` verifies CSR PoP, that the CSR's embedded key
|
||||
* == `subjectPubkey` (the SAME-KEY check — the route passes the CURRENT cert's key), and
|
||||
* that the registry host is active with a matching key; it stamps `host.subdomain`.
|
||||
* - device: `signDeviceLeaf(deviceId, csr)` verifies CSR PoP, the device is active, and the CSR key
|
||||
* == the registered key; it stamps `record.subdomainScope`.
|
||||
* Renewal adds the leaf's `notAfter`, read back from the emitted cert (byte-exact with the DER) for
|
||||
* BOTH paths so the `/renew` routes can echo it. It ALSO extends device validity: the host signer
|
||||
* recomputes `now()+ttl` on every call, but the device signer stamps the record's `notAfter` (set once
|
||||
* at enroll), so device renewal first bumps that record via the `DeviceRegistry` — otherwise every
|
||||
* device renewal would reproduce the original enrollment expiry.
|
||||
*
|
||||
* `reflect-metadata` must load before `@peculiar/x509` (tsyringe polyfill) — keep the side-effect first.
|
||||
*/
|
||||
import type { HostStore } from '../store/ports.js'
|
||||
import type { CaSigner } from '../boot/ca-wiring.js'
|
||||
import { verifyCsrPoP } from './csr.js'
|
||||
import { LeafSignError } from './sign.js'
|
||||
import { timingSafeEqualBytes, bytesToBase64 } from '../util/bytes.js'
|
||||
import 'reflect-metadata'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { webcrypto } from 'node:crypto'
|
||||
import type { LeafSigner } from './sign.js'
|
||||
import type { DeviceLeafSigner } from './device-issue.js'
|
||||
import type { DeviceExpiryRenewer } from '../registry/devices.js'
|
||||
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
/** A renewed leaf: the re-issued DER, its CA chain, and the parsed expiry `/renew` echoes back. */
|
||||
export interface RenewedLeaf {
|
||||
readonly cert: Uint8Array
|
||||
readonly caChain: readonly Uint8Array[]
|
||||
readonly notAfter: Date
|
||||
}
|
||||
|
||||
export interface LeafRenewerDeps {
|
||||
readonly hosts: HostStore
|
||||
readonly signer: CaSigner
|
||||
readonly caChainDer: readonly Uint8Array[]
|
||||
readonly leafTtlSec?: number
|
||||
/** Host frp-client P-256 issuer (`frpclient-issue`). Routes through `assembleCertificate`. */
|
||||
readonly hostSigner: LeafSigner
|
||||
/** Device P-256 issuer (`device-issue`). Routes through `assembleCertificate`. */
|
||||
readonly deviceSigner: DeviceLeafSigner
|
||||
/**
|
||||
* Device leaf-expiry renewer (the `DeviceRegistry`). MUST share the SAME `DeviceStore` as
|
||||
* `deviceSigner` so the bumped expiry is visible to the signer's gate. The host path recomputes
|
||||
* `now()+ttl` inside its signer; the device signer reads `record.notAfter`, so device renewal must
|
||||
* bump that record FIRST or every renewal reproduces the original enrollment expiry.
|
||||
*/
|
||||
readonly deviceExpiry: DeviceExpiryRenewer
|
||||
}
|
||||
|
||||
export interface LeafRenewer {
|
||||
renewHostLeaf(
|
||||
hostId: string,
|
||||
csr: Uint8Array,
|
||||
): Promise<{ cert: Uint8Array; caChain: readonly Uint8Array[] }>
|
||||
/**
|
||||
* Re-issue the host frp-client leaf. `subjectPubkey` is the CURRENT cert's SubjectPublicKeyInfo DER
|
||||
* (supplied by the mTLS-authenticated `/renew` route); passing it makes the delegated gate enforce
|
||||
* BOTH the SAME-KEY rule (CSR key == current cert key) AND registry consistency in one check. The
|
||||
* re-issued leaf's subdomain/SPIFFE come from the registry record — never from `csr` or the caller.
|
||||
*/
|
||||
renewHostLeaf(hostId: string, subjectPubkey: Uint8Array, csr: Uint8Array): Promise<RenewedLeaf>
|
||||
/**
|
||||
* Re-issue the device leaf. The delegated gate enforces CSR PoP + active + CSR key == registered key;
|
||||
* the SAN is stamped from `record.subdomainScope` (SAME scope — no smuggling).
|
||||
*/
|
||||
renewDeviceLeaf(deviceId: string, csr: Uint8Array): Promise<RenewedLeaf>
|
||||
}
|
||||
|
||||
const DEFAULT_LEAF_TTL_SEC = 24 * 60 * 60
|
||||
|
||||
export function createLeafRenewer(deps: LeafRenewerDeps): LeafRenewer {
|
||||
const ttl = deps.leafTtlSec ?? DEFAULT_LEAF_TTL_SEC
|
||||
return {
|
||||
async renewHostLeaf(hostId, csr) {
|
||||
const host = await deps.hosts.get(hostId)
|
||||
// A revoked/absent host cannot renew (INV12 + INV14).
|
||||
if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered')
|
||||
|
||||
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')
|
||||
|
||||
const notAfter = Math.floor(Date.now() / 1000) + ttl
|
||||
const tbs = new TextEncoder().encode(
|
||||
JSON.stringify({ v: 1, hostId, subjectSpki: bytesToBase64(host.agentPubkey), notAfter, renewed: true }),
|
||||
)
|
||||
const sig = await deps.signer.sign(tbs) // KMS sign(); no raw key
|
||||
const cert = new TextEncoder().encode(
|
||||
JSON.stringify({ tbs: bytesToBase64(tbs), sig: bytesToBase64(sig) }),
|
||||
)
|
||||
return { cert, caChain: deps.caChainDer }
|
||||
async renewHostLeaf(hostId, subjectPubkey, csr) {
|
||||
// Delegated gate (frpclient-issue) rejects UNIFORMLY (LeafSignError) on any failure; issuance is
|
||||
// never reached. The re-issued leaf stamps host.subdomain from the registry (anti-smuggling).
|
||||
const { cert, caChain } = await deps.hostSigner.signHostLeaf(hostId, subjectPubkey, csr)
|
||||
const notAfter = new x509.X509Certificate(cert).notAfter // authoritative expiry, read from the cert
|
||||
return { cert, caChain, notAfter }
|
||||
},
|
||||
async renewDeviceLeaf(deviceId, csr) {
|
||||
// EXTEND validity FIRST: the device signer stamps `record.notAfter`, which is set ONCE at enroll,
|
||||
// so without this bump every renewal reproduces the original enrollment expiry (and eventually
|
||||
// issues already-expired certs). The registry recomputes + persists `now()+ttl` for an active
|
||||
// device, keeping the record (CRL/expiry pairing source of truth) consistent with the emitted
|
||||
// cert; an unknown/revoked device is left untouched and the delegated gate below rejects it.
|
||||
await deps.deviceExpiry.renewDeviceExpiry(deviceId)
|
||||
// Delegated gate (device-issue) rejects UNIFORMLY (DeviceLeafSignError) on any failure.
|
||||
const leaf = await deps.deviceSigner.signDeviceLeaf(deviceId, csr)
|
||||
// Defense in depth: surface the expiry actually embedded in the DER (byte-exact with the cert),
|
||||
// exactly as `renewHostLeaf` does — never a value that could drift from what was signed.
|
||||
const notAfter = new x509.X509Certificate(leaf.cert).notAfter
|
||||
return { cert: leaf.cert, caChain: leaf.caChain, notAfter }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
177
control-plane/src/ca/x509-assembler.ts
Normal file
177
control-plane/src/ca/x509-assembler.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* A1 / FIX C-1 — the SINGLE X.509 issuance primitive for the native-tunnel PKI.
|
||||
*
|
||||
* WHY THIS EXISTS: `@peculiar/x509`'s `X509CertificateGenerator.create` needs a `signingKey:
|
||||
* CryptoKey` and CANNOT delegate to an async KMS. Real-X.509 AND KMS-non-exportable signing are
|
||||
* therefore mutually exclusive as built. This module resolves that: it DER-encodes the v3
|
||||
* `TBSCertificate` itself (reusing the battle-tested `@peculiar/asn1-x509` schemas — no hand-rolled
|
||||
* full-certificate DER) and signs the SERIALIZED TBS by calling `CaSigner.sign(tbsDer)` — the KMS
|
||||
* boundary. The raw CA private key is NEVER loaded in this module (INV9). All future issuers (host
|
||||
* frp-client, device, renew, CRL) route through this one primitive.
|
||||
*
|
||||
* `reflect-metadata` must load before `@peculiar/asn1-schema`/`@peculiar/x509` (tsyringe polyfill) —
|
||||
* keep the side-effect import first.
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { AsnConvert } from '@peculiar/asn1-schema'
|
||||
import {
|
||||
TBSCertificate,
|
||||
Certificate,
|
||||
AlgorithmIdentifier,
|
||||
Name,
|
||||
Validity,
|
||||
SubjectPublicKeyInfo,
|
||||
Extension,
|
||||
Extensions,
|
||||
Version,
|
||||
} from '@peculiar/asn1-x509'
|
||||
import { ECDSASigValue } from '@peculiar/asn1-ecc'
|
||||
import { webcrypto } from 'node:crypto'
|
||||
import type { CaSigner } from '../boot/ca-wiring.js'
|
||||
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
/** Signature-algorithm family the CA signs with. Drives OID + signatureValue encoding. */
|
||||
export type SigAlgFamily = 'ed25519' | 'ecdsa-p256'
|
||||
|
||||
/** OID 1.3.101.112 (Ed25519); no algorithm parameters. */
|
||||
const OID_ED25519 = '1.3.101.112'
|
||||
/** OID 1.2.840.10045.4.3.2 (ecdsa-with-SHA256); no algorithm parameters. */
|
||||
const OID_ECDSA_WITH_SHA256 = '1.2.840.10045.4.3.2'
|
||||
/** Raw P1363 (r||s) length for a P-256 signature — the WebCrypto/hardware-native ECDSA shape. */
|
||||
const P256_P1363_LEN = 64
|
||||
/** Raw Ed25519 signature length — the fixed 64 bytes embedded verbatim in the signatureValue BIT STRING. */
|
||||
const ED25519_SIG_LEN = 64
|
||||
|
||||
export interface AssembleCertificateInput {
|
||||
/** Subject public key: a WebCrypto public `CryptoKey` OR its SubjectPublicKeyInfo DER. */
|
||||
readonly subjectPublicKey: CryptoKey | Uint8Array
|
||||
/** Subject Distinguished Name (an `x509.Name` or a DN string like `CN=alice`). */
|
||||
readonly subject: x509.Name | string
|
||||
/** Issuer Distinguished Name — MUST equal the signing CA's subject so `checkIssued` matches. */
|
||||
readonly issuer: x509.Name | string
|
||||
/** Positive serial-number bytes (big-endian). Sign/leading-zero normalized internally. */
|
||||
readonly serialNumber: Uint8Array
|
||||
readonly notBefore: Date
|
||||
readonly notAfter: Date
|
||||
/** Pre-built extensions (SAN, BasicConstraints, KeyUsage, EKU, …) as `@peculiar/x509` objects. */
|
||||
readonly extensions: readonly x509.Extension[]
|
||||
/** KMS-shaped CA signer. `sign(tbsDer)` is the only crypto touchpoint; raw key never loaded. */
|
||||
readonly signer: CaSigner
|
||||
/** Declared signature-algorithm family — MUST match what `signer.sign` produces. */
|
||||
readonly sigAlg: SigAlgFamily
|
||||
}
|
||||
|
||||
/** Copy a `Uint8Array` view into a standalone `ArrayBuffer` (never a `SharedArrayBuffer`). */
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize big-endian bytes into the content octets of a DER positive INTEGER: strip leading zero
|
||||
* bytes (keeping at least one), then prepend `0x00` if the high bit is set so the value can never be
|
||||
* read as negative. The asn1 INTEGER converter uses these bytes verbatim, so this MUST run first.
|
||||
*/
|
||||
function toPositiveIntegerBytes(raw: Uint8Array): Uint8Array {
|
||||
if (raw.length === 0) throw new Error('integer bytes must be non-empty')
|
||||
let start = 0
|
||||
while (start < raw.length - 1 && raw[start] === 0x00) start++
|
||||
const trimmed = raw.subarray(start)
|
||||
if ((trimmed[0]! & 0x80) === 0) return trimmed
|
||||
const prefixed = new Uint8Array(trimmed.length + 1)
|
||||
prefixed.set(trimmed, 1)
|
||||
return prefixed
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an ECDSA signature to the DER `ECDSA-Sig-Value ::= SEQUENCE { INTEGER r, INTEGER s }`
|
||||
* the X.509 signatureValue BIT STRING requires. Accepts BOTH shapes a signer may return:
|
||||
* - raw P1363 `r || s` (64 bytes for P-256, the WebCrypto / Secure-Enclave / StrongBox shape) →
|
||||
* split and re-encode each half as a positive INTEGER;
|
||||
* - already-DER `ECDSA-Sig-Value` (Node `crypto.sign` default) → validated and passed through.
|
||||
* Throws on anything that is neither, so a malformed signer surfaces at issuance, not on the wire.
|
||||
*/
|
||||
export function normalizeEcdsaSignatureToDer(sig: Uint8Array): Uint8Array {
|
||||
if (sig.length === P256_P1363_LEN) {
|
||||
const half = sig.length / 2
|
||||
const r = toPositiveIntegerBytes(sig.subarray(0, half))
|
||||
const s = toPositiveIntegerBytes(sig.subarray(half))
|
||||
const value = new ECDSASigValue({ r: toArrayBuffer(r), s: toArrayBuffer(s) })
|
||||
return new Uint8Array(AsnConvert.serialize(value))
|
||||
}
|
||||
// Not raw P1363 — require a valid DER ECDSA-Sig-Value, else reject (fail loud at issuance).
|
||||
try {
|
||||
AsnConvert.parse(toArrayBuffer(sig), ECDSASigValue)
|
||||
return sig
|
||||
} catch {
|
||||
throw new Error('ECDSA signature is neither raw P1363 (64 bytes) nor DER ECDSA-Sig-Value')
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a subject key (CryptoKey or SPKI DER) to an asn1 `SubjectPublicKeyInfo`. */
|
||||
async function toSpki(key: CryptoKey | Uint8Array): Promise<SubjectPublicKeyInfo> {
|
||||
const der = key instanceof Uint8Array ? key : new Uint8Array(await webcrypto.subtle.exportKey('spki', key))
|
||||
return AsnConvert.parse(toArrayBuffer(der), SubjectPublicKeyInfo)
|
||||
}
|
||||
|
||||
/** Convert an `x509.Name` or DN string to an asn1 `Name` via its canonical DER. */
|
||||
function toAsnName(name: x509.Name | string): Name {
|
||||
const der = name instanceof x509.Name ? name.toArrayBuffer() : new x509.Name(name).toArrayBuffer()
|
||||
return AsnConvert.parse(der, Name)
|
||||
}
|
||||
|
||||
/** The OID for a signature family; identical instance-value in tbs.signature and outer sigAlg. */
|
||||
function algorithmOid(sigAlg: SigAlgFamily): string {
|
||||
return sigAlg === 'ed25519' ? OID_ED25519 : OID_ECDSA_WITH_SHA256
|
||||
}
|
||||
|
||||
/** Encode the raw CA-signer output into the certificate signatureValue for the declared family. */
|
||||
function encodeSignatureValue(sigAlg: SigAlgFamily, rawSig: Uint8Array): Uint8Array {
|
||||
// Ed25519: the raw 64-byte signature goes directly into the BIT STRING. Validate the length first —
|
||||
// a signer that returns anything but exactly 64 bytes (truncated/malformed) would otherwise embed a
|
||||
// structurally-invalid signature; fail loud at issuance rather than emit an unverifiable cert.
|
||||
if (sigAlg === 'ed25519') {
|
||||
if (rawSig.length !== ED25519_SIG_LEN) {
|
||||
throw new Error(
|
||||
`Ed25519 signature must be exactly ${ED25519_SIG_LEN} bytes, got ${rawSig.length}`,
|
||||
)
|
||||
}
|
||||
return rawSig
|
||||
}
|
||||
return normalizeEcdsaSignatureToDer(rawSig)
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble a signed X.509 v3 leaf certificate DER. Builds the `TBSCertificate`, serializes it,
|
||||
* signs the SERIALIZED TBS via `signer.sign` (KMS boundary — raw key never loaded), then wraps it as
|
||||
* `Certificate { tbsCertificate, signatureAlgorithm, signatureValue }`. The SAME `AlgorithmIdentifier`
|
||||
* OID is set in BOTH `tbsCertificate.signature` and `certificate.signatureAlgorithm` (X.509 requires
|
||||
* them identical). The exact signed TBS bytes are embedded verbatim (`tbsCertificateRaw`), so the
|
||||
* emitted certificate's signature always covers precisely what was signed.
|
||||
*/
|
||||
export async function assembleCertificate(input: AssembleCertificateInput): Promise<Uint8Array> {
|
||||
const oid = algorithmOid(input.sigAlg)
|
||||
const tbs = new TBSCertificate({
|
||||
version: Version.v3,
|
||||
serialNumber: toArrayBuffer(toPositiveIntegerBytes(input.serialNumber)),
|
||||
signature: new AlgorithmIdentifier({ algorithm: oid }),
|
||||
issuer: toAsnName(input.issuer),
|
||||
validity: new Validity({ notBefore: input.notBefore, notAfter: input.notAfter }),
|
||||
subject: toAsnName(input.subject),
|
||||
subjectPublicKeyInfo: await toSpki(input.subjectPublicKey),
|
||||
extensions: new Extensions(input.extensions.map((e) => AsnConvert.parse(e.rawData, Extension))),
|
||||
})
|
||||
|
||||
const tbsDer = new Uint8Array(AsnConvert.serialize(tbs))
|
||||
const rawSig = await input.signer.sign(tbsDer)
|
||||
const signatureValue = encodeSignatureValue(input.sigAlg, rawSig)
|
||||
|
||||
const certificate = new Certificate({
|
||||
tbsCertificate: tbs,
|
||||
tbsCertificateRaw: toArrayBuffer(tbsDer), // embed the EXACT bytes that were signed
|
||||
signatureAlgorithm: new AlgorithmIdentifier({ algorithm: oid }),
|
||||
signatureValue: toArrayBuffer(signatureValue),
|
||||
})
|
||||
return new Uint8Array(AsnConvert.serialize(certificate))
|
||||
}
|
||||
Reference in New Issue
Block a user