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,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 }
}

View 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 }
},
}
}

View 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 }
},
}
}

View File

@@ -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 }
},
}
}

View 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))
}