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