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,188 @@
/**
* A4 — device enrollment HTTP routes (registerable fastify plugin; wired into `main.ts` later, so it
* is testable standalone via `app.inject`). Deny-by-default, uniform reject:
*
* POST /device/enroll [Bearer device:enroll]
* verify bearer through the SAME `CapabilityVerifier` seam the admin API uses (boot/verifier.ts)
* + assert the `enroll` right → accountId; Zod-validate the body; verify the P-256 CSR PoP;
* enforce the per-account device CAP + enroll RATE-LIMIT; register the device; issue the leaf via
* `device-issue`; 201 {deviceId, cert, caChain, notBefore, notAfter, renewAfter}.
*
* POST /device/attest/challenge [Bearer device:enroll]
* STUB (attestation verification deferred, §1.1): mint + short-TTL-bind a random challenge.
*
* `POST /device/:id/renew` is A6 — NOT built here.
*/
import { z } from 'zod'
import { randomBytes } from 'node:crypto'
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import type { CapabilityVerifier } from './authz.js'
import { DEVICE_ENROLL_AUD, DeviceEnrollAuthError, requireEnrollRight } from '../auth/session.js'
import type { DeviceRegistry, AttestationLevel } from '../registry/devices.js'
import { DeviceLimitError } from '../registry/devices.js'
import type { DeviceLeafSigner } from '../ca/device-issue.js'
import { DeviceLeafSignError } from '../ca/device-issue.js'
import { decodeCsrWire } from '../ca/csr.js'
import { verifyCsrPoPEc } from '../ca/csr-ec.js'
import { normalizeSubdomain, isValidSubdomain } from '../subdomain/assign.js'
import { bytesToBase64 } from '../util/bytes.js'
/** Attestation challenge stub TTL (seconds). */
export const ATTEST_CHALLENGE_TTL_SEC = 120
/** Renew at ~2/3 of the leaf lifetime by default. */
const DEFAULT_RENEW_FRACTION = 2 / 3
/**
* Subdomain-ownership source of truth (FIX C-native-3 / H-native-4). The enroll route must NOT trust
* the client-supplied `subdomain`: it is burned verbatim into the device leaf's dNSName SAN, which
* nginx :8470 treats as the SINGLE load-bearing tenant-isolation control (a cert stamped
* `alice.<base>` may ONLY reach `alice.*`). So before issuance the route resolves who actually owns
* the requested subdomain and rejects unless it is the authenticated account. In production this is
* backed by the host-onboarding registry (`HostStore.getBySubdomain(sub)?.accountId ?? null`); tests
* inject a fake. Returns `null` for an unclaimed/unknown subdomain (deny-by-default).
*/
export interface SubdomainOwnershipResolver {
ownerOfSubdomain(subdomain: string): Promise<string | null>
}
/** Client asked for a malformed/reserved subdomain — never allowed to reach a certificate SAN. */
export class SubdomainRejectedError extends Error {
constructor() {
super('subdomain rejected') // uniform message — never leak which rule failed
this.name = 'SubdomainRejectedError'
}
}
export interface DeviceEnrollDeps {
/** Same seam the admin API uses (boot/verifier.ts). */
readonly verifier: CapabilityVerifier
readonly devices: DeviceRegistry
readonly signer: DeviceLeafSigner
/** Subdomain→owner resolver (host-onboarding source of truth). REQUIRED — deny-by-default. */
readonly ownership: SubdomainOwnershipResolver
readonly enrollAud?: string
readonly renewFraction?: number
/** Clock (epoch seconds) — injectable for tests. */
readonly now?: () => number
}
const EnrollBodySchema = z
.object({
csr: z.string().min(1),
keyAlg: z.literal('ec-p256'),
subdomain: z.string().min(1).max(63),
deviceName: z.string().min(1).max(128),
attestation: z.string().min(1).optional(),
})
.strict()
function extractBearer(req: FastifyRequest): string | null {
const auth = req.headers['authorization']
if (typeof auth === 'string' && auth.startsWith('Bearer ')) return auth.slice('Bearer '.length).trim()
return null
}
/** Map any thrown error to a uniform HTTP reject — never leak which internal check failed. */
function sendError(reply: FastifyReply, err: unknown): void {
if (err instanceof DeviceEnrollAuthError) {
void reply.code(err.status).send({ error: 'rejected' })
return
}
if (err instanceof DeviceLimitError) {
void reply.code(429).send({ error: 'rate_limited' })
return
}
if (err instanceof DeviceLeafSignError || err instanceof SubdomainRejectedError || err instanceof z.ZodError) {
void reply.code(400).send({ error: 'rejected' })
return
}
void reply.code(400).send({ error: 'rejected' })
}
export function buildDeviceEnrollRouter(deps: DeviceEnrollDeps): FastifyPluginAsync {
const enrollAud = deps.enrollAud ?? DEVICE_ENROLL_AUD
const renewFraction = deps.renewFraction ?? DEFAULT_RENEW_FRACTION
const now = deps.now ?? (() => Math.floor(Date.now() / 1000))
/** Verify + right-check the device:enroll bearer via the injected verifier. Returns accountId. */
async function requireEnrollPrincipal(req: FastifyRequest): Promise<string> {
const raw = extractBearer(req)
if (raw === null) throw new DeviceEnrollAuthError(401, 'missing device enroll token')
let token
try {
token = await deps.verifier.verify(raw, enrollAud, now())
} catch {
throw new DeviceEnrollAuthError(401, 'device enroll token rejected')
}
requireEnrollRight(token) // 403 if the token lacks the enroll right
return token.sub // accountId derives ONLY from the verified token (INV3)
}
return async (app) => {
app.post('/device/enroll', async (req, reply) => {
try {
const accountId = await requireEnrollPrincipal(req)
const body = EnrollBodySchema.parse(req.body)
// Canonicalize + gate the requested subdomain BEFORE it can reach a certificate SAN. Charset
// (RFC-1123) + reserved-name rules are shared with host onboarding (subdomain/assign.ts) so an
// infra label like 'admin'/'www'/'api' can never be minted into a leaf. → 400.
const subdomain = normalizeSubdomain(body.subdomain)
if (!isValidSubdomain(subdomain)) throw new SubdomainRejectedError()
// Tenant-isolation gate (FIX C-native-3 / H-native-4): the device leaf's dNSName SAN is nginx's
// SINGLE load-bearing tenant boundary, so a device:enroll bearer for account A must NOT be able
// to mint a cert scoped to account B's (or a reserved) subdomain. Assert the authenticated
// account OWNS the subdomain against the host-onboarding source of truth; deny-by-default with a
// uniform reject (unknown vs. foreign-owned are indistinguishable — no cross-tenant oracle). 403.
const owner = await deps.ownership.ownerOfSubdomain(subdomain)
if (owner === null || owner !== accountId) {
throw new DeviceEnrollAuthError(403, 'subdomain not owned by account')
}
// Proof-of-possession: a valid P-256 PKCS#10 self-signature. Uniform reject on any failure.
const pop = await verifyCsrPoPEc(decodeCsrWire(body.csr))
if (!pop.ok) throw new DeviceLeafSignError('csr_rejected')
// Per-account resource controls BEFORE consuming a device slot (leaked-bootstrap blast radius).
await deps.devices.assertUnderCap(accountId)
deps.devices.checkRateLimit(accountId)
const attestationLevel: AttestationLevel = body.attestation !== undefined ? 'software' : 'none'
const record = await deps.devices.registerDevice({
accountId,
subdomainScope: subdomain, // the normalized, ownership-verified label (never the raw client field)
ecPubkeySpki: pop.embeddedPubSpki,
attestationLevel,
})
const leaf = await deps.signer.signDeviceLeaf(record.deviceId, decodeCsrWire(body.csr))
const lifeMs = leaf.notAfter.getTime() - leaf.notBefore.getTime()
const renewAfter = new Date(leaf.notBefore.getTime() + Math.floor(lifeMs * renewFraction)).toISOString()
await reply.code(201).send({
deviceId: record.deviceId,
cert: bytesToBase64(leaf.cert),
caChain: leaf.caChain.map((c) => bytesToBase64(c)),
notBefore: leaf.notBefore.toISOString(),
notAfter: leaf.notAfter.toISOString(),
renewAfter,
})
} catch (err) {
sendError(reply, err)
}
})
app.post('/device/attest/challenge', async (req, reply) => {
try {
await requireEnrollPrincipal(req)
// STUB: mint a random challenge with a short TTL. Attestation VERIFICATION is deferred (§1.1);
// the challenge is issued here so the client flow (challenge → keygen → CSR) is already shaped.
const challenge = Buffer.from(randomBytes(32)).toString('base64url')
await reply.code(200).send({ challenge, expires_in: ATTEST_CHALLENGE_TTL_SEC })
} catch (err) {
sendError(reply, err)
}
})
}
}

View File

@@ -15,7 +15,9 @@ 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 type { NativeHostEnroller } from '../pairing/native-redeem.js'
import { decodeCsrWire } from '../ca/csr.js'
import { isEcP256Csr } from '../ca/csr-ec.js'
import type { Deprovisioner } from '../deprovision/deprovision.js'
export interface ProvisionDeps {
@@ -25,6 +27,12 @@ export interface ProvisionDeps {
readonly pairingIssuer: PairingIssuer
readonly redeemer: PairingRedeemer
readonly deprovisioner: Deprovisioner
/**
* Native (EC-P256) frp-client enrollment arm. When present, an EC-P256 CSR on `/enroll` is routed
* here (P-256 frp-client leaf); Ed25519 CSRs always take the relay `redeemer`. Optional/additive:
* absent → EC CSRs fall through to the relay arm, which rejects a non-Ed25519 key (deny-by-default).
*/
readonly nativeEnroller?: NativeHostEnroller
}
const PlanSchema = z.enum(['free', 'personal', 'pro', 'team'])
@@ -33,7 +41,10 @@ const EnrollSchema = z.object({
code: z.string().min(1),
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
machineId: z.string().min(1).max(256).optional(), // native arm only; accepted + audited, no dedup yet (M-cp-idempotent)
})
// NOT `.strict()`: any client-supplied `subdomain` (or other extra field) is INERT — the native arm's
// SAN comes ONLY from the server-assigned host binding (A4 anti-smuggling), never from the body.
function sendError(reply: FastifyReply, err: unknown): void {
if (err instanceof AuthzError) {
@@ -124,11 +135,31 @@ export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
// NOT capability-token gated — guarded by the single-use pairing code (T8).
try {
const body = EnrollSchema.parse(req.body)
const result = await deps.redeemer.redeemPairingCode({
code: body.code,
agentPubkey: new Uint8Array(Buffer.from(body.agentPubkey, 'base64')),
csr: decodeCsrWire(body.csr),
})
const agentPubkey = new Uint8Array(Buffer.from(body.agentPubkey, 'base64'))
const csr = decodeCsrWire(body.csr)
// Fork by CSR key algorithm: an EC-P256 CSR is a NATIVE frp-client host → P-256 leaf; an
// Ed25519 CSR is the legacy relay host (unchanged). Routing is a pure SPKI parse; each arm
// verifies the CSR self-signature inside its own gate.
if (deps.nativeEnroller !== undefined && isEcP256Csr(csr)) {
const native = await deps.nativeEnroller.enrollNativeHost({
code: body.code,
agentPubkey,
csr,
...(body.machineId !== undefined ? { machineId: body.machineId } : {}),
})
// Native tunnel has no E2E-relay content secret (L-host-hcs); cert/caChain are base64(DER).
await reply.code(201).send({
hostId: native.hostId,
subdomain: native.subdomain,
cert: Buffer.from(native.cert).toString('base64'),
caChain: native.caChain.map((der) => Buffer.from(der).toString('base64')),
hostContentSecret: null,
})
return
}
const result = await deps.redeemer.redeemPairingCode({ code: body.code, agentPubkey, csr })
// base64URL (not base64) — the agent decodes this via decodeBase64UrlBytes (enroll/pair.ts).
await reply.code(201).send({ ...result, hostContentSecret: Buffer.from(result.hostContentSecret).toString('base64url') })
} catch (err) {

View File

@@ -0,0 +1,358 @@
/**
* A6 (FIX H-host-3) — leaf RENEWAL routes (registerable fastify plugin; testable standalone via
* `app.inject`, wired into `main.ts` later). Both routes are authenticated by the CURRENT valid client
* certificate the mTLS terminator (nginx / frps) already verified — NEVER by anything in the request
* body. Deny-by-default, uniform reject.
*
* POST /renew [mTLS current frp-client cert]
* Identity (accountId, subdomain, current pubkey) is extracted from the PRESENTED cert's SANs
* (SPIFFE URI `.../host/<sub>` via relay-auth `parseSpiffeId`, subject key from the cert), NOT the
* body. Look the host up BY THAT SUBDOMAIN, require active + account-consistent, then re-issue via
* the host signer with the CURRENT cert's key as the subject key — so the delegated gate enforces
* the SAME-KEY rule (no key swap) and stamps `host.subdomain` (the SAME subdomain — no smuggling).
* 201 { cert, caChain, notAfter }.
*
* POST /device/:id/renew [mTLS current device cert]
* Identity (accountId, deviceId, current pubkey) from the presented device cert's SANs. Look the
* device record up by :id, require active + account/deviceId/key-consistent with the presented
* cert, then re-issue via the device signer (stamps `record.subdomainScope`). 201.
*
* CRITICAL (A4 anti-smuggling lesson): a renew can NEVER change the subdomain, account, or key. The
* identity comes from the authenticated current cert + the existing registry record; the body carries
* ONLY the new CSR. The re-issued SAN is built from the registry record, never from client input.
*
* `reflect-metadata` must load before `@peculiar/x509` (tsyringe polyfill) — keep the side-effect first.
*/
import 'reflect-metadata'
import * as x509 from '@peculiar/x509'
import { webcrypto, X509Certificate as NodeX509Certificate } from 'node:crypto'
import { z } from 'zod'
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import { parseSpiffeId, type SpiffeKind } from 'relay-auth/src/agent/spiffe.js'
import { verifyChain } from 'relay-auth/src/agent/verify-mtls.js'
import type { HostRegistry } from '../registry/hosts.js'
import type { DeviceRegistry } from '../registry/devices.js'
import type { LeafRenewer } from '../ca/rotate.js'
import { decodeCsrWire } from '../ca/csr.js'
import { bytesToBase64, timingSafeEqualBytes } from '../util/bytes.js'
x509.cryptoProvider.set(webcrypto)
/** Default per-identity renewal budget (window). Renewal is ~2/3-TTL cadence — abuse is a signal. */
export const DEFAULT_RENEW_RATE_MAX = 30
/** Renewal rate window (ms). */
export const DEFAULT_RENEW_RATE_WINDOW_MS = 60 * 60 * 1000
/**
* Hard cap on distinct identities the in-memory limiter tracks. Without a bound the `hits` Map grows
* one entry per unique presented identity forever (memory-DoS); at the cap we sweep fully-expired
* windows and, if still over, evict the least-recently-seen entries. Evicting an idle entry only
* resets a limiter that was about to expire anyway, so the rate guarantee for active identities holds.
*/
export const RENEW_RATE_MAX_IDENTITIES = 10_000
/** Max base64 length of a submitted CSR (a P-256 PKCS#10 is well under 1 KB; this is generous slack). */
export const MAX_CSR_B64_LEN = 8192
/**
* Default header the mTLS terminator forwards the verified client cert in (base64 DER). The terminator
* MUST set this from `$ssl_client_cert` AND strip any client-supplied copy — a client can never provide
* its own current cert. Production wiring can swap in a socket-peer-cert resolver instead.
*/
export const DEFAULT_CLIENT_CERT_HEADER = 'x-client-cert'
/** Uniform reject: 401 = no/invalid current cert (unauthenticated); 403 = cert valid but not allowed. */
export class RenewRejectError extends Error {
constructor(public readonly status: 401 | 403) {
super('renew rejected') // uniform message — never leak which check failed
this.name = 'RenewRejectError'
}
}
/** Per-identity renewal rate exceeded → 429. */
export class RenewRateLimitError extends Error {
constructor() {
super('renew rate limited')
this.name = 'RenewRateLimitError'
}
}
/** The authenticated identity extracted from a presented client cert (never from the body). */
interface CertIdentity {
readonly accountId: string
/** SPIFFE id segment: the subdomain for a host cert, the deviceId for a device cert. */
readonly id: string
readonly kind: SpiffeKind
/** SubjectPublicKeyInfo DER of the current cert's subject key (the SAME-KEY anchor). */
readonly publicKeySpki: Uint8Array
}
/** Seam for the current mTLS client cert. The terminator provides it; tests inject it. */
export interface PresentedClientCert {
/** DER of the client cert the mTLS layer verified for THIS request, or null if none was presented. */
presentedCertDer(req: FastifyRequest): Uint8Array | null
}
/** Per-identity sliding-window rate limiter for renewals. */
export interface RenewRateLimiter {
/** Throws `RenewRateLimitError` when `identity` is over its renewal budget. */
check(identity: string): void
/** Number of identities currently tracked (bounded) — for tests/observability. */
size(): number
}
export interface RenewRateLimitOpts {
readonly max?: number
readonly windowMs?: number
/** Clock (ms) — injectable for tests. */
readonly now?: () => number
}
/** Optional cap override — defaults to `RENEW_RATE_MAX_IDENTITIES`. */
export interface RenewRateLimitBoundOpts extends RenewRateLimitOpts {
readonly maxIdentities?: number
}
/**
* In-process sliding-window renewal limiter (mirrors `registry/devices.ts` `checkRateLimit`), with a
* bounded `hits` Map (memory-DoS guard). Each check drops the identity's timestamps older than the
* window; when the Map reaches `maxIdentities` it sweeps every fully-expired identity and, if still at
* the cap, evicts the least-recently-seen entries so the Map can never grow without bound.
*/
export function createRenewRateLimiter(opts: RenewRateLimitBoundOpts = {}): RenewRateLimiter {
const max = opts.max ?? DEFAULT_RENEW_RATE_MAX
const windowMs = opts.windowMs ?? DEFAULT_RENEW_RATE_WINDOW_MS
const maxIdentities = opts.maxIdentities ?? RENEW_RATE_MAX_IDENTITIES
const now = opts.now ?? (() => Date.now())
const hits = new Map<string, number[]>()
/** Drop identities whose entire window has expired; if still at the cap, evict oldest-seen entries. */
function boundMap(cutoff: number): void {
for (const [id, arr] of hits) {
const recent = arr.filter((t) => t > cutoff)
if (recent.length === 0) hits.delete(id)
else hits.set(id, recent)
}
if (hits.size < maxIdentities) return
// All remaining windows are still active but we are at the cap — evict the least-recently-seen
// (smallest max-timestamp) down to below the cap. This bounds memory under a distinct-identity flood.
const byRecency = [...hits.entries()].sort((a, b) => Math.max(...a[1]) - Math.max(...b[1]))
const evict = hits.size - maxIdentities + 1
for (let i = 0; i < evict && i < byRecency.length; i++) hits.delete(byRecency[i]![0])
}
return {
check(identity) {
const ts = now()
const cutoff = ts - windowMs
// Sweep before inserting a NEW identity so the Map can never exceed the cap.
if (!hits.has(identity) && hits.size >= maxIdentities) boundMap(cutoff)
const recent = (hits.get(identity) ?? []).filter((t) => t > cutoff)
if (recent.length >= max) {
hits.set(identity, recent) // persist the pruned window; do NOT record this rejected attempt
throw new RenewRateLimitError()
}
recent.push(ts)
hits.set(identity, recent)
},
size() {
return hits.size
},
}
}
/**
* Default `PresentedClientCert` reading the base64-DER cert the mTLS terminator forwarded in a header.
* The terminator is trusted to set the header from the VERIFIED client cert and to strip any inbound
* copy; this resolver never trusts a client-supplied value on an unterminated path.
*/
export function headerPresentedCert(headerName: string = DEFAULT_CLIENT_CERT_HEADER): PresentedClientCert {
const name = headerName.toLowerCase()
return {
presentedCertDer(req) {
const raw = req.headers[name]
const value = Array.isArray(raw) ? raw[0] : raw
if (typeof value !== 'string' || value.length === 0) return null
try {
return new Uint8Array(Buffer.from(value, 'base64'))
} catch {
return null
}
},
}
}
/**
* Trust-anchor verification of the presented current cert (defense in depth: the mTLS terminator has
* already verified it, but this route must not trust a cert the terminator would never have accepted).
* Mirrors relay-auth `verify-mtls` `verifyChain`: full path validation to a self-signed CA anchor in
* the supplied set (the frp-client-CA for host renews, the device-CA for device renews) PLUS the
* cert's own notBefore/notAfter validity window. ANY failure throws `RenewRejectError(401)`.
*/
function assertPresentedCertTrusted(
der: Uint8Array,
caAnchorsDer: readonly Uint8Array[],
nowMs: number,
): void {
let leaf: NodeX509Certificate
let anchors: NodeX509Certificate[]
try {
leaf = new NodeX509Certificate(Buffer.from(der))
anchors = caAnchorsDer.map((a) => new NodeX509Certificate(Buffer.from(a)))
} catch {
throw new RenewRejectError(401)
}
if (!verifyChain(leaf, anchors)) throw new RenewRejectError(401)
const notBefore = new Date(leaf.validFrom).getTime()
const notAfter = new Date(leaf.validTo).getTime()
if (Number.isNaN(notBefore) || Number.isNaN(notAfter)) throw new RenewRejectError(401)
if (nowMs < notBefore || nowMs > notAfter) throw new RenewRejectError(401)
}
/**
* Extract the authenticated identity from a presented client cert. Parses the DER, reads the SPIFFE URI
* SAN through relay-auth's OWN `parseSpiffeId` (so it can never drift from the verifier), requires the
* expected kind, and returns the subject public key SPKI. ANY failure → 401 (unauthenticated). This is
* the ONLY identity source — no client-supplied field is ever consulted. When `verify` is supplied (the
* kind's CA anchor set + clock), the presented cert is additionally chain- and expiry-validated against
* that anchor BEFORE its identity is trusted; production wiring MUST supply anchors.
*/
function parsePresentedCertIdentity(
der: Uint8Array,
expectedKind: SpiffeKind,
verify?: { readonly caAnchorsDer: readonly Uint8Array[]; readonly nowMs: number },
): CertIdentity {
let leaf: x509.X509Certificate
try {
leaf = new x509.X509Certificate(der)
} catch {
throw new RenewRejectError(401)
}
if (verify !== undefined) assertPresentedCertTrusted(der, verify.caAnchorsDer, verify.nowMs)
const san = leaf.getExtension(x509.SubjectAlternativeNameExtension)
const uri = san?.names.toJSON().find((n) => n.type === 'url')?.value
if (uri === undefined) throw new RenewRejectError(401)
let parsed: { accountId: string; id: string; kind: SpiffeKind }
try {
parsed = parseSpiffeId(uri)
} catch {
throw new RenewRejectError(401)
}
if (parsed.kind !== expectedKind) throw new RenewRejectError(401)
return {
accountId: parsed.accountId,
id: parsed.id,
kind: parsed.kind,
publicKeySpki: new Uint8Array(leaf.publicKey.rawData),
}
}
const RenewBodySchema = z.object({ csr: z.string().min(1).max(MAX_CSR_B64_LEN) }).strict()
export interface RenewDeps {
readonly hosts: HostRegistry
readonly devices: DeviceRegistry
readonly renewer: LeafRenewer
/** Current-cert seam (mTLS terminator provides it). Defaults to the header resolver. */
readonly presentedCert?: PresentedClientCert
/** Per-identity renewal rate limiter. Defaults to an in-process sliding window. */
readonly rateLimiter?: RenewRateLimiter
/**
* frp-client-CA anchor DER(s). When supplied, the presented current HOST cert is chain- and
* expiry-validated against these before its identity is trusted (defense in depth). Production
* wiring MUST supply them; omitted only by legacy callers/tests that inject an already-trusted cert.
*/
readonly hostCaAnchorsDer?: readonly Uint8Array[]
/** device-CA anchor DER(s) — same role for the DEVICE renew path. */
readonly deviceCaAnchorsDer?: readonly Uint8Array[]
}
/** Map any thrown error to a uniform HTTP reject — never leak which internal check failed. */
function sendError(reply: FastifyReply, err: unknown): void {
if (err instanceof RenewRejectError) {
void reply.code(err.status).send({ error: 'rejected' })
return
}
if (err instanceof RenewRateLimitError) {
void reply.code(429).send({ error: 'rate_limited' })
return
}
// LeafSignError / DeviceLeafSignError / ZodError / decode failures / anything else → uniform 400.
void reply.code(400).send({ error: 'rejected' })
}
export function buildRenewRouter(deps: RenewDeps): FastifyPluginAsync {
const presentedCert = deps.presentedCert ?? headerPresentedCert()
const rateLimiter = deps.rateLimiter ?? createRenewRateLimiter()
return async (app) => {
app.post('/renew', async (req, reply) => {
try {
const der = presentedCert.presentedCertDer(req)
if (der === null) throw new RenewRejectError(401)
const identity = parsePresentedCertIdentity(
der,
'host',
deps.hostCaAnchorsDer ? { caAnchorsDer: deps.hostCaAnchorsDer, nowMs: Date.now() } : undefined,
)
rateLimiter.check(`host:${identity.accountId}:${identity.id}`)
// Identity is the SUBDOMAIN from the cert — resolve the host the registry actually holds for it.
// A revoked, unknown, or account-inconsistent binding is forbidden (deny-by-default). 403.
const host = await deps.hosts.getHostBySubdomain(identity.id)
if (host === null || host.status === 'revoked' || host.accountId !== identity.accountId) {
throw new RenewRejectError(403)
}
const body = RenewBodySchema.parse(req.body)
// Pass the CURRENT cert's key as the subject key → the delegated gate enforces CSR key ==
// current cert key (SAME-KEY, no swap) AND registered key == current cert key; it stamps
// host.subdomain (SAME subdomain — the body cannot smuggle a different one).
const leaf = await deps.renewer.renewHostLeaf(host.hostId, identity.publicKeySpki, decodeCsrWire(body.csr))
await reply.code(201).send({
cert: bytesToBase64(leaf.cert),
caChain: leaf.caChain.map((c) => bytesToBase64(c)),
notAfter: leaf.notAfter.toISOString(),
})
} catch (err) {
sendError(reply, err)
}
})
app.post('/device/:id/renew', async (req, reply) => {
try {
const der = presentedCert.presentedCertDer(req)
if (der === null) throw new RenewRejectError(401)
const identity = parsePresentedCertIdentity(
der,
'device',
deps.deviceCaAnchorsDer ? { caAnchorsDer: deps.deviceCaAnchorsDer, nowMs: Date.now() } : undefined,
)
const id = (req.params as { id: string }).id
rateLimiter.check(`device:${identity.id}`)
// The registry record for :id is the identity source of truth. Require it active AND that the
// presented cert genuinely belongs to it: same account, same deviceId as the path, same key.
const record = await deps.devices.getDevice(id)
if (
record === null ||
record.status === 'revoked' ||
record.accountId !== identity.accountId ||
identity.id !== id ||
!timingSafeEqualBytes(record.ecPubkeySpki, identity.publicKeySpki)
) {
throw new RenewRejectError(403)
}
const body = RenewBodySchema.parse(req.body)
// The delegated gate re-checks CSR key == record.ecPubkeySpki (== current cert key, verified
// above) → SAME-KEY; it stamps record.subdomainScope (SAME scope — no smuggling).
const leaf = await deps.renewer.renewDeviceLeaf(id, decodeCsrWire(body.csr))
await reply.code(201).send({
cert: bytesToBase64(leaf.cert),
caChain: leaf.caChain.map((c) => bytesToBase64(c)),
notAfter: leaf.notAfter.toISOString(),
})
} catch (err) {
sendError(reply, err)
}
})
}
}

View File

@@ -0,0 +1,166 @@
/**
* A4 (FIX C-native-1) — the MINIMAL device:enroll session-token subsystem + a login stub seam.
*
* `/device/enroll` is the one endpoint that cannot require a client cert (chicken-and-egg), so it is
* gated by a short-lived, narrowly-scoped `device:enroll` bearer minted after the user's one-time
* login. That bearer IS a §4.3 capability token with `rights:['enroll']` (the right added in A2a),
* `sub = accountId`, a DISTINCT audience (`device-enroll`), and a MINUTES-scale TTL that is
* deliberately SEPARATE from the 3060s connect clamp.
*
* WHY NOT `issueCapabilityToken`: relay-auth's connect-token issuer hard-clamps TTL to ≤60s (the
* Finding-4 blast-radius clamp) and requires a single-host + DPoP `cnf.jkt`. An enroll token is not
* host-scoped and must live for minutes, so we mint via the SAME underlying primitive
* (`signPaseto`, Ed25519 v4.public — relay-auth crypto, NO new crypto) and build the identical §4.3
* body shape so the FROZEN verifier (`verifyCapabilityToken`) accepts it unchanged. Verification
* therefore goes through the exact path the control-plane already uses (boot/verifier.ts), then
* asserts the `enroll` right — deny-by-default on anything else.
*
* LOGIN is a STUB SEAM for the single-tenant MVP (`loginToAccountId`): it resolves an authenticated
* credential to an `accountId`. A full end-user auth layer (RFC 8628 device grant / OIDC) layers on
* here later WITHOUT changing the token shape or the verify path.
*/
import type { CapabilityRight, CapabilityToken } from 'relay-contracts'
import { verifyCapabilityToken } from 'relay-auth'
import { signPaseto } from 'relay-auth/src/crypto/paseto.js'
import { randomBytes, randomUUID } from 'node:crypto'
import { timingSafeEqualBytes } from '../util/bytes.js'
/** Distinct audience for device enrollment (Host-confusion guard — never a subdomain aud). */
export const DEVICE_ENROLL_AUD = 'device-enroll'
/** Default enroll-token TTL: 10 minutes (minutes-scale, separate from the 3060s connect clamp). */
export const DEFAULT_DEVICE_ENROLL_TTL_SEC = 10 * 60
/** Floor: a device:enroll token must outlive the connect clamp to be meaningfully separate. */
export const MIN_DEVICE_ENROLL_TTL_SEC = 60
/** Ceiling: still short-lived (leaked-bearer blast radius). */
export const MAX_DEVICE_ENROLL_TTL_SEC = 60 * 60
/** Enroll tokens are not host-scoped; the frozen §4.3 shape requires a non-empty `host` — sentinel. */
const ENROLL_HOST_SENTINEL = 'device-enroll'
/** Uniform auth reject for the device-enroll surface. 401 = bad/missing token, 403 = lacks right. */
export class DeviceEnrollAuthError extends Error {
constructor(
public readonly status: 401 | 403,
message = 'device enrollment auth rejected',
) {
super(message)
this.name = 'DeviceEnrollAuthError'
}
}
/** The frozen §4.3 body + the additive `cnf.jkt` claim the verifier requires (RFC 7800). */
interface EnrollTokenBody {
readonly sub: string
readonly aud: string
readonly host: string
readonly rights: readonly CapabilityRight[]
readonly iat: number
readonly exp: number
readonly jti: string
readonly cnf: { readonly jkt: string }
}
export interface MintDeviceEnrollOpts {
/** The session subsystem's Ed25519 signing key (WebCrypto). Never held globally (INV9). */
readonly signingKey: CryptoKey
readonly ttlSeconds?: number
readonly now?: number // epoch seconds
readonly aud?: string
/** Optional DPoP thumbprint. Enroll-token PoP binding is deferred; a placeholder is used if absent. */
readonly cnfJkt?: string
readonly jti?: string
}
function clampTtl(ttl: number): number {
return Math.min(Math.max(ttl, MIN_DEVICE_ENROLL_TTL_SEC), MAX_DEVICE_ENROLL_TTL_SEC)
}
/** A 43-char base64url placeholder satisfying the shared verifier's `cnf.jkt` (min-1) requirement. */
function placeholderJkt(): string {
return Buffer.from(randomBytes(32)).toString('base64url')
}
/**
* Mint a `device:enroll` capability token: `rights:['enroll']`, `sub = accountId`, `aud = device-enroll`,
* minutes-scale TTL. Signed with the caller-supplied Ed25519 key via relay-auth's PASETO primitive.
*/
export async function mintDeviceEnrollToken(accountId: string, opts: MintDeviceEnrollOpts): Promise<string> {
if (accountId.length === 0) throw new DeviceEnrollAuthError(401, 'empty accountId')
const now = opts.now ?? Math.floor(Date.now() / 1000)
const ttl = clampTtl(opts.ttlSeconds ?? DEFAULT_DEVICE_ENROLL_TTL_SEC)
const body: EnrollTokenBody = {
sub: accountId,
aud: opts.aud ?? DEVICE_ENROLL_AUD,
host: ENROLL_HOST_SENTINEL,
rights: ['enroll'],
iat: now,
exp: now + ttl,
jti: opts.jti ?? randomUUID(),
cnf: { jkt: opts.cnfJkt ?? placeholderJkt() },
}
return signPaseto(body, opts.signingKey)
}
/** Least-privilege check: the bearer must carry the `enroll` right, else 403 (uniform). */
export function requireEnrollRight(token: CapabilityToken): void {
if (!token.rights.includes('enroll')) {
throw new DeviceEnrollAuthError(403, 'token scope lacks the enroll right')
}
}
export interface VerifyDeviceEnrollOpts {
readonly now?: number // epoch seconds
readonly aud?: string
}
/**
* Verify a `device:enroll` bearer through the FROZEN §4.3 verifier (same path as boot/verifier.ts):
* Ed25519 signature (startup key), `aud === device-enroll`, and `iat`/`exp` window — then assert the
* `enroll` right. Returns `{ accountId }` (from the token `sub`, never a client field). Uniform reject:
* any signature/audience/expiry failure → 401; a missing `enroll` right → 403.
*/
export async function verifyDeviceEnrollToken(
raw: string,
opts: VerifyDeviceEnrollOpts = {},
): Promise<{ accountId: string }> {
const aud = opts.aud ?? DEVICE_ENROLL_AUD
const now = opts.now ?? Math.floor(Date.now() / 1000)
let token: CapabilityToken
try {
token = await verifyCapabilityToken(raw, aud, now)
} catch {
throw new DeviceEnrollAuthError(401, 'device enroll token rejected')
}
requireEnrollRight(token)
return { accountId: token.sub }
}
/**
* LOGIN STUB SEAM (single-tenant MVP). Resolves an authenticated credential to an `accountId`.
* Deny-by-default: an empty credential, an unresolved credential, or an unconfigured seam all reject.
* A full end-user auth layer (RFC 8628 device grant / OIDC) replaces the body here without touching
* callers. Two supported bindings:
* - `resolve(credential) → accountId | null` — an injectable resolver (the real login layer);
* - `{ operatorCredential, accountId }` — the single operator credential of the MVP fleet.
*/
export interface LoginSeamConfig {
readonly resolve?: (credential: string) => string | null
readonly operatorCredential?: string
readonly accountId?: string
}
export function loginToAccountId(credential: string, config: LoginSeamConfig): string {
if (credential.length === 0) throw new DeviceEnrollAuthError(401, 'login rejected')
if (config.resolve !== undefined) {
const acct = config.resolve(credential)
if (acct === null || acct.length === 0) throw new DeviceEnrollAuthError(401, 'login rejected')
return acct
}
if (config.operatorCredential !== undefined && config.accountId !== undefined) {
const a = new TextEncoder().encode(credential)
const b = new TextEncoder().encode(config.operatorCredential)
if (timingSafeEqualBytes(a, b)) return config.accountId
throw new DeviceEnrollAuthError(401, 'login rejected')
}
throw new DeviceEnrollAuthError(401, 'login not configured')
}

View File

@@ -5,8 +5,14 @@
* the key ref is unresolvable OR its policy does not restrict `sign` to the control-plane
* service principal. Both T8 `signHostLeaf` and T15 `renewHostLeaf` call this shared primitive —
* neither loads a raw key.
*
* Two in-process dev/test signers implement the SAME `CaSigner` surface (never a KMS — production
* mandates a real non-exportable key): `inProcessCaSigner` (Ed25519, the relay agent-CA path) and
* `inProcessP256CaSigner` (ECDSA-with-SHA256, the native-tunnel `frp-client-CA` / `device-CA` P-256
* path). The P-256 signer returns a raw P1363 `r||s` signature — the shape hardware emits — which
* `x509-assembler` normalizes to DER; both expose the same `sign()` so callers stay KMS-shaped.
*/
import { createPublicKey } from 'node:crypto'
import { createPublicKey, generateKeyPairSync, sign as nodeSign } from 'node:crypto'
import type { ControlPlaneEnv } from '../env.js'
import { ed25519Sign, generateEd25519, type KeyObject } from '../util/crypto.js'
@@ -73,3 +79,22 @@ function derivePublicRaw(privateKey: KeyObject): Uint8Array {
const spki = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }) as Buffer
return new Uint8Array(spki.subarray(spki.length - 32))
}
/**
* In-process P-256 (ECDSA-with-SHA256) CA signer — TEST/DEV ONLY, same disclaimer as the Ed25519
* variant. This is the dev stand-in for the native-tunnel `frp-client-CA` / `device-CA` (both P-256).
* `sign()` returns a RAW P1363 `r||s` (64-byte) signature over the TBS — the same shape hardware
* (Secure Enclave / StrongBox / WebCrypto) emits — which `x509-assembler` normalizes to DER.
* `publicKeyRaw` carries the CA's SubjectPublicKeyInfo DER (not a bare point) so tests can import it
* directly as a verifying key.
*/
export function inProcessP256CaSigner(privateKey?: KeyObject): CaSigner {
const key = privateKey ?? generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey
const spkiDer = createPublicKey(key).export({ format: 'der', type: 'spki' }) as Buffer
return {
publicKeyRaw: new Uint8Array(spkiDer),
async sign(tbsCert) {
return new Uint8Array(nodeSign('sha256', Buffer.from(tbsCert), { key, dsaEncoding: 'ieee-p1363' }))
},
}
}

View File

@@ -0,0 +1,155 @@
/**
* Native-tunnel CA boot wiring. Builds the TWO P-256 CAs of the native-tunnel PKI — `frp-client-CA`
* (host frp-client leaves) and `device-CA` (device data-path leaves) — as
* `{ signer, caCertDer, anchorsDer, issuerName }`. Both are SEPARATE trust roots from the Ed25519
* relay agent-CA (§1.3); each is P-256 (the VPS `gen-device-ca.sh` "P-256 never Ed25519" rule).
*
* DEV/TEST path (default): generate a self-signed P-256 CA whose key is `inProcessP256CaSigner`, so
* every issued leaf chains to a REAL, re-parseable CA (mirrors `rotate.test.ts` `makeP256Ca`). This
* is NOT a KMS — the plan mandates a real non-exportable key in production (§3.1).
*
* PROD path: resolve each CA's KMS-non-exportable P-256 signer via `buildCaSigner` (reusing its INV9
* key-policy fail-fast) with a PER-CA KMS key ref, plus that CA's (public) cert DER loaded from
* configured `material`. FAIL-FAST (INV9) if the native-CA material is unresolvable in production —
* never silently fall back to an in-process dev CA. The env does not yet carry per-CA key refs /
* native-CA cert paths, so production wiring supplies them via `material`; the documented follow-up
* is to add `NATIVE_FRP_CLIENT_CA_*` / `NATIVE_DEVICE_CA_*` (KMS ref + cert path) to `env.ts` and map
* them here.
*
* `reflect-metadata` must load before `@peculiar/x509` (tsyringe polyfill) — keep the side-effect first.
*/
import 'reflect-metadata'
import * as x509 from '@peculiar/x509'
import { webcrypto, generateKeyPairSync } from 'node:crypto'
import type { ControlPlaneEnv } from '../env.js'
import { assembleCertificate } from '../ca/x509-assembler.js'
import { buildCaSigner, inProcessP256CaSigner, type CaSigner, type KmsResolver } from './ca-wiring.js'
x509.cryptoProvider.set(webcrypto)
/** DNS zone the reachable subdomain lives under (leftmost label = the nginx :8470 enforcement key). */
export const DEFAULT_NATIVE_DNS_ZONE = 'terminal.yaojia.wang'
/** Dev CA validity: 1y forward, backdated 1d for clock skew (dev-only self-signed anchor). */
const DEV_CA_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000
const DEV_CA_BACKDATE_MS = 24 * 60 * 60 * 1000
/** One native CA: its KMS-shaped signer, its (public) CA cert DER, the anchor set, and issuer DN. */
export interface NativeCa {
readonly signer: CaSigner
/** DER of the CA's own (self-signed in dev) certificate. */
readonly caCertDer: Uint8Array
/** Trust-anchor DER set the renew route chain-validates presented certs against (non-empty). */
readonly anchorsDer: readonly Uint8Array[]
/** The CA subject DN — used verbatim as the leaf issuer so `checkIssued` matches. */
readonly issuerName: x509.Name
}
/** The two native-tunnel CAs. */
export interface NativeCas {
readonly frpClientCa: NativeCa
readonly deviceCa: NativeCa
}
/** Production material for one CA: its KMS key ref + its (public) CA cert DER. */
export interface NativeCaMaterialItem {
readonly kmsKeyRef: string
readonly caCertDer: Uint8Array
}
/** Production material for BOTH native CAs (supplied by the deployment, not generated). */
export interface NativeCaMaterial {
readonly frpClientCa: NativeCaMaterialItem
readonly deviceCa: NativeCaMaterialItem
}
export interface BuildNativeCasOpts {
/** Production mode → fail-fast unless real KMS-backed material is supplied (INV9). */
readonly production?: boolean
/** KMS resolver (per-CA key ref → non-exportable P-256 signer). Required in production. */
readonly kmsResolver?: KmsResolver
/** Production-loaded per-CA material (KMS ref + public cert DER). Required in production. */
readonly material?: NativeCaMaterial
/** Clock (ms) — injectable for tests. */
readonly now?: () => number
}
/** Parse a CA cert DER into an `x509.Name` issuer, failing loud on unparseable material (INV9). */
function issuerNameOf(caCertDer: Uint8Array): x509.Name {
try {
return new x509.X509Certificate(caCertDer).subjectName
} catch {
throw new Error('native-CA certificate material is not a parseable X.509 certificate — refusing to boot')
}
}
/**
* DEV/TEST: a self-signed P-256 CA (subject key == signer key) so issued leaves chain to a real CA.
* BasicConstraints CA:true + KeyUsage keyCertSign|cRLSign, exactly like the test fixtures.
*/
async function buildDevNativeCa(cn: string, nowMs: number): Promise<NativeCa> {
const caKeys = generateKeyPairSync('ec', { namedCurve: 'P-256' })
const caSpki = new Uint8Array(caKeys.publicKey.export({ format: 'der', type: 'spki' }))
const signer = inProcessP256CaSigner(caKeys.privateKey)
const caCertDer = await assembleCertificate({
subjectPublicKey: caSpki,
subject: `CN=${cn}`,
issuer: `CN=${cn}`,
serialNumber: Uint8Array.from([0x01]),
notBefore: new Date(nowMs - DEV_CA_BACKDATE_MS),
notAfter: new Date(nowMs + DEV_CA_VALIDITY_MS),
extensions: [
new x509.BasicConstraintsExtension(true, undefined, true),
new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true),
],
signer,
sigAlg: 'ecdsa-p256',
})
return { signer, caCertDer, anchorsDer: [caCertDer], issuerName: issuerNameOf(caCertDer) }
}
/**
* PROD: resolve a KMS-non-exportable P-256 signer for this CA's key ref (reusing `buildCaSigner`'s
* INV9 policy fail-fast) and pair it with the supplied (public) CA cert DER as the trust anchor.
*/
async function buildProdNativeCa(
item: NativeCaMaterialItem,
env: ControlPlaneEnv,
kmsResolver: KmsResolver,
): Promise<NativeCa> {
// Reuse the intermediate-key policy check by targeting this CA's key ref (INV9, §3.1).
const signer = await buildCaSigner({ ...env, caIntermediateKmsKeyRef: item.kmsKeyRef }, kmsResolver)
return {
signer,
caCertDer: item.caCertDer,
anchorsDer: [item.caCertDer],
issuerName: issuerNameOf(item.caCertDer),
}
}
/**
* Build both native-tunnel CAs. DEV default → self-signed in-process P-256 CAs. PRODUCTION → resolve
* KMS-backed signers + loaded CA certs from `material`; FAIL-FAST if either is missing (INV9 — never
* boot a public-shell PKI on ephemeral, unattested in-process keys).
*/
export async function buildNativeCas(env: ControlPlaneEnv, opts: BuildNativeCasOpts = {}): Promise<NativeCas> {
const production = opts.production ?? false
if (production) {
if (opts.material === undefined || opts.kmsResolver === undefined) {
throw new Error(
'native-tunnel CA material is unresolvable in production (per-CA KMS key refs + CA certs required) — refusing to boot',
)
}
const [frpClientCa, deviceCa] = await Promise.all([
buildProdNativeCa(opts.material.frpClientCa, env, opts.kmsResolver),
buildProdNativeCa(opts.material.deviceCa, env, opts.kmsResolver),
])
return { frpClientCa, deviceCa }
}
const nowMs = opts.now?.() ?? Date.now()
const [frpClientCa, deviceCa] = await Promise.all([
buildDevNativeCa('frp-client-CA', nowMs),
buildDevNativeCa('device-CA', nowMs),
])
return { frpClientCa, deviceCa }
}

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

View File

@@ -23,6 +23,7 @@ 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 { createNativeHostEnroller } from './pairing/native-redeem.js'
import { createLeafSigner, type LeafSigner } from './ca/sign.js'
import { loadRealLeafSigner } from './ca/issue.js'
import { createRoutingTable } from './routing/table.js'
@@ -33,6 +34,17 @@ import { createAuthorizer, type CapabilityVerifier } from './api/authz.js'
import { buildRouter } from './api/provision.js'
import { buildCaSigner, inProcessCaSigner, type KmsResolver, type CaSigner } from './boot/ca-wiring.js'
import { configureCapabilityVerifyKey } from './boot/verifier.js'
import {
createDeviceRegistry,
createMemoryDeviceStore,
type DeviceRegistry,
} from './registry/devices.js'
import { createFrpClientLeafSigner } from './ca/frpclient-issue.js'
import { createDeviceLeafSigner, type DeviceLeafSigner } from './ca/device-issue.js'
import { createLeafRenewer } from './ca/rotate.js'
import { buildDeviceEnrollRouter, type SubdomainOwnershipResolver } from './api/device-enroll.js'
import { buildRenewRouter } from './api/renew.js'
import { buildNativeCas, DEFAULT_NATIVE_DNS_ZONE, type NativeCas, type NativeCaMaterial } from './boot/native-ca.js'
import type { RevocationBus } from 'relay-contracts'
export interface ControlPlaneOverrides {
@@ -41,6 +53,29 @@ export interface ControlPlaneOverrides {
readonly bus?: RevocationBus & Partial<TestableRevocationBus>
readonly kmsResolver?: KmsResolver
readonly caChainDer?: readonly Uint8Array[]
/** Production mode → native-CA + renew-anchor material is fail-closed (INV9). Defaults to NODE_ENV. */
readonly production?: boolean
/** Production-loaded native-tunnel CA material (per-CA KMS ref + public cert DER). */
readonly nativeCaMaterial?: NativeCaMaterial
/** DNS zone the native-tunnel leaves are stamped under (defaults to `terminal.yaojia.wang`). */
readonly nativeDnsZone?: string
}
/** Native-tunnel PKI handles exposed for wiring + tests (the enroll/renew issuers behind the routes). */
export interface NativeTunnelHandles {
readonly nativeCas: NativeCas
/** Host frp-client P-256 leaf signer (used at onboarding to mint the first leaf). */
readonly hostSigner: LeafSigner
/** Device P-256 leaf signer (shares the device store with the registry + renew path). */
readonly deviceSigner: DeviceLeafSigner
/** Device registry (ownership + cap/rate + expiry-renewal source of truth). */
readonly deviceRegistry: DeviceRegistry
}
export interface BuiltControlPlane {
readonly app: FastifyInstance
readonly stores: Stores
readonly nativeTunnel: NativeTunnelHandles
}
/**
@@ -97,7 +132,7 @@ function devKmsResolver(): KmsResolver {
export async function buildControlPlane(
env: ControlPlaneEnv,
overrides: ControlPlaneOverrides = {},
): Promise<{ app: FastifyInstance; stores: Stores }> {
): Promise<BuiltControlPlane> {
const stores = overrides.stores ?? createMemoryStores()
const bus: RevocationBus = overrides.bus ?? createInMemoryRevocationBus()
const caChainDer = overrides.caChainDer ?? []
@@ -142,7 +177,81 @@ export async function buildControlPlane(
expectedAud: env.baseDomain,
})
// ── Native-tunnel PKI: frp-client-CA (host) + device-CA (device), both P-256 (§1.3) ───────────────
// Production is fail-closed: `buildNativeCas` refuses to boot without real KMS-backed material, and
// the renew route MUST validate presented certs against non-empty anchors (renew.ts). DEV generates
// self-signed in-process P-256 CAs so leaves chain to a real, re-parseable CA.
const production = overrides.production ?? process.env.NODE_ENV === 'production'
const nativeCas = await buildNativeCas(env, {
production,
...(overrides.kmsResolver !== undefined ? { kmsResolver: overrides.kmsResolver } : {}),
...(overrides.nativeCaMaterial !== undefined ? { material: overrides.nativeCaMaterial } : {}),
})
const nativeDnsZone = overrides.nativeDnsZone ?? DEFAULT_NATIVE_DNS_ZONE
// ONE DeviceStore shared across the device registry, the device signer, and the renew path so the
// signer's gate + renew-expiry bump all see the device the enroll route just registered.
const deviceStore = createMemoryDeviceStore()
const deviceRegistry = createDeviceRegistry({ devices: deviceStore })
const hostSigner = createFrpClientLeafSigner({
hosts: stores.hosts,
signer: nativeCas.frpClientCa.signer,
issuerName: nativeCas.frpClientCa.issuerName,
caChainDer: nativeCas.frpClientCa.anchorsDer,
trustDomain: env.relayTrustDomain,
dnsZone: nativeDnsZone,
})
// Native (EC-P256) `/enroll` arm: pairing-gated host onboarding that mints the FIRST frp-client leaf
// (mirrors the Ed25519 relay redeemer, but issues a P-256 leaf under a SERVER-assigned subdomain and
// returns no E2E-relay content secret — L-host-hcs). Shares the frp-client `hostSigner` above so the
// enroll-issued leaf and the later /renew leaf are stamped by the SAME CA + subdomain.
const nativeEnroller = createNativeHostEnroller({
pairing: stores.pairing,
hosts,
subdomains,
hostSigner,
pairingMaxRedeemAttempts: env.pairingMaxRedeemAttempts,
audit,
})
const deviceSigner = createDeviceLeafSigner({
signer: nativeCas.deviceCa.signer,
issuer: nativeCas.deviceCa.issuerName,
caChainDer: nativeCas.deviceCa.anchorsDer,
sanBaseDomain: nativeDnsZone,
trustDomain: env.relayTrustDomain,
devices: deviceStore,
})
const renewer = createLeafRenewer({ hostSigner, deviceSigner, deviceExpiry: deviceRegistry })
// The device leaf's dNSName SAN is nginx :8470's single tenant boundary, so the enroll route must
// NOT trust the client-supplied subdomain: resolve who OWNS it against the host-onboarding registry
// (deny-by-default: unknown → null). Same source of truth as `HostStore.getBySubdomain(...)`.
const ownership: SubdomainOwnershipResolver = {
async ownerOfSubdomain(subdomain) {
return (await hosts.getHostBySubdomain(subdomain))?.accountId ?? null
},
}
// Fail-closed: the renew route validates presented certs against these anchors; in production they
// MUST be non-empty (renew.ts: "production wiring MUST supply them"). Refuse to boot otherwise.
if (production && (nativeCas.frpClientCa.anchorsDer.length === 0 || nativeCas.deviceCa.anchorsDer.length === 0)) {
throw new Error('native-tunnel renew anchors must be non-empty in production (fail-closed) — refusing to boot')
}
const app = Fastify({ logger: false })
await app.register(buildRouter({ authorizer, accounts, hosts, pairingIssuer, redeemer, deprovisioner }))
return { app, stores }
await app.register(buildRouter({ authorizer, accounts, hosts, pairingIssuer, redeemer, deprovisioner, nativeEnroller }))
// Device enrollment is bearer-gated by the SAME capability verifier seam the admin API uses.
await app.register(buildDeviceEnrollRouter({ verifier, devices: deviceRegistry, signer: deviceSigner, ownership }))
// Leaf renewal is mTLS-authenticated (current client cert) — anchors chain-validate the presented cert.
await app.register(
buildRenewRouter({
hosts,
devices: deviceRegistry,
renewer,
hostCaAnchorsDer: nativeCas.frpClientCa.anchorsDer,
deviceCaAnchorsDer: nativeCas.deviceCa.anchorsDer,
}),
)
return { app, stores, nativeTunnel: { nativeCas, hostSigner, deviceSigner, deviceRegistry } }
}

View File

@@ -0,0 +1,110 @@
/**
* Native host frp-client enrollment — the EC-P256 arm of `POST /enroll` (routed by CSR key algorithm
* in api/provision.ts). Reuses the SHARED pairing gate (`gateAndConsumePairingCode`: single-use CAS +
* code-scoped lockout + expiry + PoP/no-substitution) from redeem.ts with an EC-P256 verifier, then:
* 1. assigns an AUTHORITATIVE server-side subdomain (`SubdomainAssigner`), NEVER client input;
* 2. binds the host in the ownership registry (accountId from the pairing ROW, INV3);
* 3. issues a P-256 frp-client leaf via the wired `createFrpClientLeafSigner` — the signer stamps
* the dNSName SAN from `host.subdomain` (the label bound in step 1), so a client can NEVER steer
* the SAN to a subdomain it was not assigned (A4 anti-smuggling isolation lesson).
*
* Native tunnel has NO E2E-relay content secret (L-host-hcs); the route returns `hostContentSecret:
* null`. This module returns raw leaf/CA DER; the route base64-encodes them.
*
* M-cp-idempotent (DEFERRED — documented): the frozen `HostRecordSchema` (relay-contracts, `.strict()`)
* has no `machineId` column, so machineId-keyed dedup (reinstall → SAME subdomain) is NOT implemented
* here — there is nowhere authoritative to persist it for lookup. A reinstall redeems a fresh pairing
* code and receives a fresh subdomain. `machineId` is accepted at the boundary and audited only.
*/
import type { PairingStore } from '../store/ports.js'
import type { HostRegistry } from '../registry/hosts.js'
import type { SubdomainAssigner } from '../subdomain/assign.js'
import type { LeafSigner } from '../ca/sign.js'
import type { AuditWriter } from '../audit/log.js'
import { noopAuditWriter } from '../audit/log.js'
import { gateAndConsumePairingCode, type CsrPopVerifier } from './redeem.js'
import { verifyCsrPoPEc } from '../ca/csr-ec.js'
import { fingerprint } from '../ca/fingerprint.js'
import { nowIso } from '../util/ids.js'
/** `POST /enroll` native (EC-P256) body, post-boundary-validation. `machineId` is accepted but unused (see header). */
export interface NativeHostEnrollInput {
readonly code: string
/** EC P-256 SubjectPublicKeyInfo DER (NOT a raw-32 Ed25519 key). */
readonly agentPubkey: Uint8Array
/** PKCS#10 CSR DER (P-256). */
readonly csr: Uint8Array
readonly machineId?: string
}
/** Raw issuance output — the route base64-encodes `cert`/`caChain` and appends `hostContentSecret: null`. */
export interface NativeEnrollResult {
readonly hostId: string
readonly subdomain: string
/** frp-client leaf DER. */
readonly cert: Uint8Array
/** frp-client-CA chain DER (the host's trust bundle). */
readonly caChain: readonly Uint8Array[]
}
export interface NativeHostEnrollerDeps {
readonly pairing: PairingStore
readonly hosts: HostRegistry
readonly subdomains: SubdomainAssigner
/** The wired P-256 frp-client leaf signer (`createFrpClientLeafSigner`). */
readonly hostSigner: LeafSigner
readonly pairingMaxRedeemAttempts: number
readonly audit?: AuditWriter
}
export interface NativeHostEnroller {
enrollNativeHost(input: NativeHostEnrollInput): Promise<NativeEnrollResult>
}
/** Adapt the EC-P256 PoP verifier to the shared gate's `{ ok, embeddedPub }` shape. */
const ecCsrVerifier: CsrPopVerifier = async (csr) => {
const result = await verifyCsrPoPEc(csr)
return { ok: result.ok, embeddedPub: result.embeddedPubSpki }
}
/**
* Build the native host enroller. Every successful enroll: consumes the pairing code once, binds the
* host under a SERVER-assigned subdomain, and mints a P-256 frp-client leaf whose dNSName SAN is that
* same authoritative subdomain (never client input).
*/
export function createNativeHostEnroller(deps: NativeHostEnrollerDeps): NativeHostEnroller {
const audit = deps.audit ?? noopAuditWriter()
return {
async enrollNativeHost(input) {
const { accountId } = await gateAndConsumePairingCode(
{ pairing: deps.pairing, pairingMaxRedeemAttempts: deps.pairingMaxRedeemAttempts },
{ code: input.code, agentPubkey: input.agentPubkey, csr: input.csr },
ecCsrVerifier,
)
// AUTHORITATIVE subdomain — assigned server-side from the account, never a request field (A4).
const subdomain = await deps.subdomains.assignSubdomain(accountId)
const host = await deps.hosts.bindHost({
accountId,
subdomain,
agentPubkey: input.agentPubkey,
enrollFpr: fingerprint(input.agentPubkey),
})
// The signer reads `host.subdomain` for the dNSName SAN — the SAME label bound above.
const leaf = await deps.hostSigner.signHostLeaf(host.hostId, input.agentPubkey, input.csr)
// Same `pairing.redeem` audit action as the relay arm (native IS a pairing redemption); the
// `arm: 'native'` meta discriminates the two without extending the frozen AuditAction enum.
await audit.writeAuditEvent({
action: 'pairing.redeem',
principalId: `host:${host.hostId}`,
accountId,
hostId: host.hostId,
ts: nowIso(),
meta:
input.machineId !== undefined
? { subdomain, arm: 'native', machineId: input.machineId }
: { subdomain, arm: 'native' },
})
return { hostId: host.hostId, subdomain, cert: leaf.cert, caChain: leaf.caChain }
},
}
}

View File

@@ -41,6 +41,56 @@ export interface RedeemInput {
readonly csr: Uint8Array
}
/**
* CSR proof-of-possession verifier: returns whether the self-signature is valid and the embedded
* public key. The Ed25519 relay arm passes `verifyCsrPoP` directly; the native EC-P256 arm adapts
* `verifyCsrPoPEc` to this shape. Selecting the verifier is the ONLY key-algorithm difference in the
* shared pairing gate below.
*/
export type CsrPopVerifier = (csr: Uint8Array) => Promise<{ ok: boolean; embeddedPub: Uint8Array }>
/** Just the pairing primitives + the code-scoped lockout budget the shared gate needs. */
export interface PairingGateDeps {
readonly pairing: PairingStore
readonly pairingMaxRedeemAttempts: number
}
/**
* The SHARED, security-critical pairing gate reused by BOTH /enroll arms (relay + native): lookup →
* code-scoped lockout (Finding-4) → expiry → single-use → CSR proof-of-possession + no-substitution →
* atomic single-use compare-and-set (double-spend guard). Ordered EXACTLY as the original relay path;
* `verifyCsr` selects the key algorithm. On success returns the `accountId` from the pairing ROW
* (INV3 — never a request field). Throws `RedeemError` on any failure; a bad CSR counts toward the
* code-scoped lockout. Extracted so the native arm cannot drift from the relay gate's exact ordering.
*/
export async function gateAndConsumePairingCode(
deps: PairingGateDeps,
input: RedeemInput,
verifyCsr: CsrPopVerifier,
): Promise<{ accountId: string }> {
const codeHash = sha256Hex(normalizePairingCode(input.code))
const row = await deps.pairing.get(codeHash)
if (row === null) throw new RedeemError('unknown') // blind guess; P5 limiter covers volume
// Lockout FIRST — a locked code is refused even with a correct code (Finding-4).
if (row.redeemAttempts >= deps.pairingMaxRedeemAttempts) throw new RedeemError('too_many_attempts')
if (Date.parse(row.record.expiresAt) <= Date.now()) throw new RedeemError('expired')
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 = await verifyCsr(input.csr)
if (!pop.ok || !timingSafeEqualBytes(pop.embeddedPub, input.agentPubkey)) {
await deps.pairing.registerFailure(codeHash)
throw new RedeemError('bad_csr')
}
// Atomic single-use CAS (double-spend guard) — exactly one concurrent caller wins.
const cas = await deps.pairing.casRedeem(codeHash, nowIso())
if (cas !== 'ok') throw new RedeemError('already_redeemed')
return { accountId: row.record.accountId } // from the pairing row (INV3)
}
/**
* FIX 3 — mint + WRAP the host-scoped content secret at BIND. A per-host 32-byte CSPRNG secret
* is sealed to the host's enrolled identity; the raw secret is NEVER stored or logged (INV5/INV9)
@@ -79,27 +129,13 @@ export function createPairingRedeemer(deps: RedeemDeps): PairingRedeemer {
const mint = deps.mintSecret ?? mintHostContentSecret
return {
async redeemPairingCode(input) {
const codeHash = sha256Hex(normalizePairingCode(input.code))
const row = await deps.pairing.get(codeHash)
if (row === null) throw new RedeemError('unknown') // blind guess; P5 limiter covers volume
// Lockout FIRST — a locked code is refused even with a correct code (Finding-4).
if (row.redeemAttempts >= deps.pairingMaxRedeemAttempts) throw new RedeemError('too_many_attempts')
if (Date.parse(row.record.expiresAt) <= Date.now()) throw new RedeemError('expired')
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 = await verifyCsrPoP(input.csr)
if (!pop.ok || !timingSafeEqualBytes(pop.embeddedPub, input.agentPubkey)) {
await deps.pairing.registerFailure(codeHash)
throw new RedeemError('bad_csr')
}
// Atomic single-use CAS (double-spend guard) — exactly one concurrent caller wins.
const cas = await deps.pairing.casRedeem(codeHash, nowIso())
if (cas !== 'ok') throw new RedeemError('already_redeemed')
const accountId = row.record.accountId // from the pairing row (INV3)
// Shared, security-critical gate (single-use CAS + lockout + expiry + Ed25519 PoP). Unchanged
// ordering — the native EC arm reuses the SAME gate with an EC verifier (see native-redeem.ts).
const { accountId } = await gateAndConsumePairingCode(
{ pairing: deps.pairing, pairingMaxRedeemAttempts: deps.pairingMaxRedeemAttempts },
input,
verifyCsrPoP,
)
const subdomain = await deps.subdomains.assignSubdomain(accountId)
const host = await deps.hosts.bindHost({
accountId,

View File

@@ -0,0 +1,263 @@
/**
* A4 — device registry (FIX C-native-1 / H-native-4). Mirrors `registry/hosts.ts`: the ownership
* source of truth for enrolled DEVICES (the mTLS data-path identity), keyed by an unguessable
* `deviceId` bound to an account. Only the PUBLIC P-256 key is stored (INV4). Status changes are
* versioned append-only snapshots with an atomic single-row pointer swap (INV8), exactly like
* `store/memory.ts`'s host store. Adds the per-account controls the enroll surface needs: a device
* CAP and an enroll RATE-LIMIT (leaked-bootstrap blast-radius, §5).
*
* The `DeviceStore` port lives in `store/ports.ts` (add-only); the in-memory adapter is provided
* here (`createMemoryDeviceStore`) so the device path is self-contained and does not touch the
* shared `store/memory.ts` `Stores` wiring.
*/
import type { DeviceStore } from '../store/ports.js'
import { newUuid, nowIso } from '../util/ids.js'
import { bytesToHex } from '../util/bytes.js'
import { randomBytes } from 'node:crypto'
/** Lifecycle status (INV8-versioned). */
export type DeviceStatus = 'active' | 'revoked'
/** Best-effort attestation strength recorded at enroll (verification deferred, §1.1). */
export type AttestationLevel = 'none' | 'software' | 'hardware'
/** Immutable device record. Only the PUBLIC P-256 SPKI is stored (INV4). */
export interface DeviceRecord {
readonly deviceId: string
readonly accountId: string
/**
* The subdomain label the device leaf is bound to (the nginx enforcement key, dNSName SAN). This is
* SAN-critical and must be a canonical RFC-1123 label the `accountId` actually OWNS — the caller is
* responsible for validating (subdomain/assign.ts `isValidSubdomain`) + ownership-gating it BEFORE
* `registerDevice` (see api/device-enroll.ts). The registry stores it verbatim; it does not re-check.
*/
readonly subdomainScope: string
/** P-256 SubjectPublicKeyInfo DER (the CSR-embedded public key). */
readonly ecPubkeySpki: Uint8Array
/** Issued leaf serial (hex) — authoritative expiry pairing for revocation/CRL. */
readonly serial: string
readonly status: DeviceStatus
/** ISO expiry of the issued leaf. */
readonly notAfter: string
readonly attestationLevel: AttestationLevel
readonly createdAt: string
readonly revokedAt: string | null
}
/** Append-only companion row for `device.status`/`revoked_at` versioning (INV8). */
export interface DeviceStatusVersionRow {
readonly deviceId: string
readonly version: number
readonly status: DeviceStatus
readonly revokedAt: string | null
readonly changedAt: string
readonly changedBy: string
}
/** Per-account device cap default (leaked-bootstrap blast radius, §5). */
export const DEFAULT_DEVICE_CAP = 20
/** Per-account enroll rate default within the window. */
export const DEFAULT_DEVICE_ENROLL_RATE_MAX = 20
/** Enroll rate window (ms). Mirrors the "enrollPerHour" shape. */
export const DEFAULT_DEVICE_ENROLL_RATE_WINDOW_MS = 60 * 60 * 1000
/** Device leaf TTL bounds (24h floor .. 7d ceiling, §1.3). */
export const DEFAULT_DEVICE_LEAF_TTL_SEC = 24 * 60 * 60
export const MIN_DEVICE_LEAF_TTL_SEC = 24 * 60 * 60
export const MAX_DEVICE_LEAF_TTL_SEC = 7 * 24 * 60 * 60
/** Uniform per-account limit reject → 429 at the route. `code` is machine-readable, not user-facing. */
export class DeviceLimitError extends Error {
constructor(public readonly code: 'cap_exceeded' | 'rate_limited') {
super('device enrollment limit') // uniform message — never leak counts/thresholds
this.name = 'DeviceLimitError'
}
}
export interface RegisterDeviceInput {
readonly accountId: string
readonly subdomainScope: string
readonly ecPubkeySpki: Uint8Array
readonly attestationLevel: AttestationLevel
}
/**
* Narrow renewal-time capability: recompute + persist a device's leaf expiry. Split out so the leaf
* renewer (`ca/rotate.ts`) depends only on this, not the whole registry.
*/
export interface DeviceExpiryRenewer {
/**
* Recompute + persist a fresh leaf expiry (`now()+ttl`, mirroring the host path) for a KNOWN, ACTIVE
* device; returns the updated record. Unknown/revoked devices are NOT mutated (deny-by-default) —
* the value passed through unchanged (`null` for unknown) and the downstream leaf gate rejects.
*/
renewDeviceExpiry(deviceId: string): Promise<DeviceRecord | null>
}
export interface DeviceRegistry extends DeviceExpiryRenewer {
registerDevice(input: RegisterDeviceInput): Promise<DeviceRecord>
getDevice(deviceId: string): Promise<DeviceRecord | null>
listDevices(accountId: string): Promise<readonly DeviceRecord[]>
setDeviceStatus(deviceId: string, status: DeviceStatus): Promise<DeviceRecord>
ownsDevice(accountId: string, deviceId: string): Promise<boolean>
/** Throws `DeviceLimitError('cap_exceeded')` when the account already holds `deviceCap` ACTIVE devices. */
assertUnderCap(accountId: string): Promise<void>
/** Records + checks the per-account enroll rate; throws `DeviceLimitError('rate_limited')` when over. */
checkRateLimit(accountId: string): void
}
function clampLeafTtl(ttl: number): number {
return Math.min(Math.max(ttl, MIN_DEVICE_LEAF_TTL_SEC), MAX_DEVICE_LEAF_TTL_SEC)
}
export interface DeviceRegistryDeps {
readonly devices: DeviceStore
readonly actor?: string
readonly deviceCap?: number
readonly rateMax?: number
readonly rateWindowMs?: number
readonly leafTtlSec?: number
/** Clock (ms) — injectable for tests. */
readonly now?: () => number
}
// NOTE: device lifecycle audit is intentionally NOT written here — the `AuditAction` enum
// (audit/log.ts) has no `device.*` members and extending it is out of this task's scope (a shared
// coordination point). Wire device audit once the enum gains `device.enroll`/`device.revoke`.
export function createDeviceRegistry(deps: DeviceRegistryDeps): DeviceRegistry {
const actor = deps.actor ?? 'system'
const deviceCap = deps.deviceCap ?? DEFAULT_DEVICE_CAP
const rateMax = deps.rateMax ?? DEFAULT_DEVICE_ENROLL_RATE_MAX
const rateWindowMs = deps.rateWindowMs ?? DEFAULT_DEVICE_ENROLL_RATE_WINDOW_MS
const leafTtlSec = clampLeafTtl(deps.leafTtlSec ?? DEFAULT_DEVICE_LEAF_TTL_SEC)
const now = deps.now ?? (() => Date.now())
// Per-account sliding-window enroll timestamps (in-process rate-limiter, mirrors memRouteStore state).
const rateHits = new Map<string, number[]>()
return {
async registerDevice(input) {
const ts = now()
const rec: DeviceRecord = {
deviceId: newUuid(), // unguessable UUIDv4 (INV1)
accountId: input.accountId,
subdomainScope: input.subdomainScope,
// Defensive copy → fresh ArrayBuffer-backed array (immutability + INV4 public-key-only).
ecPubkeySpki: new Uint8Array(input.ecPubkeySpki),
serial: bytesToHex(randomBytes(16)),
status: 'active',
notAfter: new Date(ts + leafTtlSec * 1000).toISOString(),
attestationLevel: input.attestationLevel,
createdAt: nowIso(),
revokedAt: null,
}
await deps.devices.insert(rec)
return rec
},
async getDevice(deviceId) {
return deps.devices.get(deviceId)
},
async listDevices(accountId) {
return deps.devices.listByAccount(accountId) // ownership-scoped
},
async setDeviceStatus(deviceId, status) {
const revokedAt = status === 'revoked' ? nowIso() : null
return deps.devices.swapStatus(deviceId, status, revokedAt, actor)
},
async renewDeviceExpiry(deviceId) {
// Fresh expiry EACH renewal (mirrors the host `now()+ttl`). Without this the device leaf would
// reproduce the ORIGINAL enrollment expiry on every renewal — eventually issuing already-expired
// certs. Deny-by-default: only KNOWN + ACTIVE devices are extended; unknown/revoked are left
// untouched (the downstream device-leaf gate produces the canonical uniform reject).
const record = await deps.devices.get(deviceId)
if (record === null || record.status === 'revoked') return record
const notAfter = new Date(now() + leafTtlSec * 1000).toISOString()
return deps.devices.renewLeafExpiry(deviceId, notAfter)
},
async ownsDevice(accountId, deviceId) {
const dev = await deps.devices.get(deviceId)
// Deny-by-default: unknown device or account mismatch ⇒ false (INV1/INV6).
return dev !== null && dev.accountId === accountId
},
async assertUnderCap(accountId) {
const active = await deps.devices.countActiveByAccount(accountId)
if (active >= deviceCap) throw new DeviceLimitError('cap_exceeded')
},
checkRateLimit(accountId) {
const ts = now()
const cutoff = ts - rateWindowMs
const hits = (rateHits.get(accountId) ?? []).filter((t) => t > cutoff)
if (hits.length >= rateMax) {
rateHits.set(accountId, hits) // persist the pruned window; do NOT record this rejected attempt
throw new DeviceLimitError('rate_limited')
}
hits.push(ts)
rateHits.set(accountId, hits)
},
}
}
// ── In-memory DeviceStore adapter (mirrors store/memory.ts memHostStore, INV8) ────────────────────
interface DeviceCell {
record: DeviceRecord
version: number
versions: DeviceStatusVersionRow[]
}
export function createMemoryDeviceStore(): DeviceStore {
const cells = new Map<string, DeviceCell>()
return {
async insert(rec) {
if (cells.has(rec.deviceId)) throw new Error('duplicate deviceId')
cells.set(rec.deviceId, {
record: rec,
version: 1,
versions: [
{
deviceId: rec.deviceId,
version: 1,
status: rec.status,
revokedAt: rec.revokedAt,
changedAt: rec.createdAt,
changedBy: 'system',
},
],
})
},
async get(id) {
return cells.get(id)?.record ?? null
},
async listByAccount(accountId) {
return [...cells.values()].filter((c) => c.record.accountId === accountId).map((c) => c.record)
},
async countActiveByAccount(accountId) {
let n = 0
for (const c of cells.values()) {
if (c.record.accountId === accountId && c.record.status === 'active') n++
}
return n
},
async swapStatus(id, status, revokedAt, changedBy) {
const cell = cells.get(id)
if (cell === undefined) throw new Error('device not found')
// --- atomic critical section (no await) ---
const nextVersion = cell.version + 1
const changedAt = new Date().toISOString()
const nextRecord: DeviceRecord = { ...cell.record, status, revokedAt }
cell.versions.push({ deviceId: id, version: nextVersion, status, revokedAt, changedAt, changedBy })
cell.record = nextRecord
cell.version = nextVersion
// --- end critical section ---
return nextRecord
},
async renewLeafExpiry(id, notAfter) {
const cell = cells.get(id)
if (cell === undefined) throw new Error('device not found')
// --- atomic critical section (no await) ---
const nextRecord: DeviceRecord = { ...cell.record, notAfter } // immutable: fresh expiry, new record
cell.record = nextRecord
// --- end critical section ---
return nextRecord
},
async versions(id) {
return (cells.get(id)?.versions ?? []).slice()
},
}
}

View File

@@ -21,6 +21,9 @@ import type {
RouteEntry,
SessionRecord,
} from '../model/records.js'
// A4 add-only (FIX C-native-1): the device data-path identity records own their types in the
// registry module (model/records.ts is frozen for this task); import them type-only here.
import type { DeviceRecord, DeviceStatus, DeviceStatusVersionRow } from '../registry/devices.js'
export interface AccountStore {
insert(rec: AccountRecord): Promise<void>
@@ -52,6 +55,35 @@ export interface SessionStore {
get(sessionId: string): Promise<SessionRecord | null>
}
/**
* A4 (add-only) — device data-path identity store (FIX C-native-1). Mirrors `HostStore`: versioned
* status snapshots (INV8) + an ACTIVE-count read for the per-account device cap. The in-memory
* adapter lives in `registry/devices.ts` (`createMemoryDeviceStore`), not `store/memory.ts`, so the
* shared `Stores` wiring is untouched.
*/
export interface DeviceStore {
insert(rec: DeviceRecord): Promise<void>
get(deviceId: string): Promise<DeviceRecord | null>
listByAccount(accountId: string): Promise<readonly DeviceRecord[]>
/** Count of ACTIVE (non-revoked) devices for the per-account cap. */
countActiveByAccount(accountId: string): Promise<number>
/** Atomic status version + pointer swap. `revokedAt` set only on 'revoked'. Returns NEW record. */
swapStatus(
deviceId: string,
status: DeviceStatus,
revokedAt: string | null,
changedBy: string,
): Promise<DeviceRecord>
/**
* Persist a fresh leaf expiry on RENEWAL (mirrors the host `now()+ttl` fresh-expiry pattern).
* Atomic single-row pointer update — the record is the CRL/expiry pairing source of truth, so it
* must track the emitted cert. NOT status-versioned (INV8 covers status, not expiry). Returns the
* NEW record. Throws if `deviceId` is unknown.
*/
renewLeafExpiry(deviceId: string, notAfter: string): Promise<DeviceRecord>
versions(deviceId: string): Promise<readonly DeviceStatusVersionRow[]>
}
/** Reservation of a subdomain label; atomic single-winner under concurrency (INV1). */
export interface SubdomainStore {
reserve(subdomain: string): Promise<boolean> // false ⇒ already taken