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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user