Correction to the previous commit. Its recovery vhost could not work: nginx
will not forward an expired client certificate in ANY `ssl_verify_client` mode.
`optional` answers a bare `400 The SSL certificate error` before any location
runs, and `optional_no_ca` only tolerates CHAIN failures — see nginx's
`ngx_ssl_verify_error_optional()`, which covers self-signed / unknown-issuer /
unverifiable-leaf and NOT `X509_V_ERR_CERT_HAS_EXPIRED`. Verified live against
the deployed :8472 server, which rejected the real expired leaf.
So recovery drops mTLS instead of trying to bend it:
- agent: `buildTlsOptions` and the mTLS renew transport go back to being
strictly fail-closed on expiry — the relaxation is gone from the TLS layer
entirely. The rotator now decides per attempt: valid → mTLS `/renew`,
expired-inside-grace → plain `/recover`, expired-beyond-grace → terminal
`onExhausted` with no request issued at all.
- new `recoverCert` POSTs `{cert, csr}` with no client certificate. Possession
of the private key is still proven: the CSR is self-signed by it and the host
signer already enforces CSR PoP plus `CSR key == registered key`, so a
replayed (public) cert without the key yields at most a certificate the
attacker cannot authenticate with.
- control-plane: `/renew` is strict again (grace 0, matching the terminator).
The grace lives on the new `POST /recover`, which reads the cert from the BODY
and ignores the `x-client-cert` header, then runs the identical trust
pipeline: X.509 path validation to the frp-client-CA anchors, SPIFFE parse,
`notBefore` (never graced), registry `active` + account match. Revocation
still bites.
- deploy: no new vhost, no new SNI, no DNS. One `location = /recover` merged
into the existing enroll vhost, documented in
`deploy/nginx/enroll-recover-location.md` with the nginx source citation.
The load-bearing new test is "a self-signed cert with a FORGED SPIFFE SAN is
refused → 401": nginx no longer validates the chain on this path, so that
assertion is what keeps `/recover` from being a cert vending machine.
Verified: agent 289/289, control-plane 290/290, tsc clean on both.
478 lines
21 KiB
TypeScript
478 lines
21 KiB
TypeScript
/**
|
|
* 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
|
|
/** Max wire length of a body-carried certificate (a P-256 leaf PEM is ~1 KB; generous slack). */
|
|
export const MAX_PRESENTED_CERT_LEN = 16384
|
|
/**
|
|
* 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'
|
|
/**
|
|
* How long after `notAfter` a presented leaf may still authenticate its OWN re-issuance (30 days).
|
|
*
|
|
* `/renew` is authenticated by the very leaf it renews, so a strict expiry check means a host whose
|
|
* leaf lapsed can never renew it — it is bricked until an operator re-pairs (this happened: a laptop
|
|
* slept through its renewal window and the tunnel stayed down for 8 days). Accepting a recently
|
|
* expired leaf FOR RE-ISSUANCE ONLY breaks that deadlock.
|
|
*
|
|
* Nothing else is relaxed: the leaf must still chain to the CA anchor set, its SPIFFE identity must
|
|
* still resolve to a registry record that is `active` and account-consistent, and `notBefore` is NOT
|
|
* graced. The residual risk is that a stale stolen leaf stays usable for this window — an attacker
|
|
* holding an unexpired stolen leaf can already renew indefinitely, so this widens an existing
|
|
* exposure rather than opening a new one. Set to 0 to restore the strict behaviour.
|
|
*/
|
|
export const DEFAULT_EXPIRED_RENEW_GRACE_MS = 30 * 24 * 60 * 60 * 1000
|
|
|
|
/** 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
|
|
return certWireToDer(value)
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalize a certificate carried over the wire to raw DER. Accepts base64 DER, PEM, and nginx's
|
|
* header-safe `$ssl_client_escaped_cert` (URL-encoded PEM): URL-decode if escaped, then strip PEM
|
|
* armor + whitespace to recover the base64 body. Returns null on anything unparseable.
|
|
*/
|
|
export function certWireToDer(value: string): Uint8Array | null {
|
|
try {
|
|
let s = value.includes('%') ? safeDecodeUri(value) : value
|
|
if (s.includes('BEGIN CERTIFICATE')) {
|
|
s = s.replace(/-----[^-]+-----/g, '').replace(/\s+/g, '')
|
|
}
|
|
const der = Buffer.from(s, 'base64')
|
|
return der.length > 0 ? new Uint8Array(der) : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** URL-decode `value`, returning it unchanged if it is not valid percent-encoding. */
|
|
function safeDecodeUri(value: string): string {
|
|
try {
|
|
return decodeURIComponent(value)
|
|
} catch {
|
|
return value
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
expiredGraceMs: 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)
|
|
// `notBefore` is never graced — a not-yet-valid cert is nonsense, not a recoverable lapse. Only
|
|
// `notAfter` gets the bounded renewal grace (see DEFAULT_EXPIRED_RENEW_GRACE_MS).
|
|
if (nowMs < notBefore) throw new RenewRejectError(401)
|
|
if (nowMs > notAfter + Math.max(0, expiredGraceMs)) 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
|
|
readonly expiredGraceMs: 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, verify.expiredGraceMs)
|
|
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()
|
|
/** `/recover` additionally carries the EXPIRED leaf itself (base64 DER or PEM). */
|
|
const RecoverBodySchema = z
|
|
.object({
|
|
cert: z.string().min(1).max(MAX_PRESENTED_CERT_LEN),
|
|
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[]
|
|
/**
|
|
* Window after `notAfter` in which a presented leaf may still authenticate its own re-issuance.
|
|
* Defaults to `DEFAULT_EXPIRED_RENEW_GRACE_MS`; 0 restores the strict fail-closed behaviour.
|
|
*/
|
|
readonly expiredRenewGraceMs?: number
|
|
}
|
|
|
|
/** 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()
|
|
// `/renew` stays STRICT (0): the mTLS terminator would never forward an expired cert to it anyway.
|
|
// The grace belongs to `/recover`, the route built for exactly that case.
|
|
const recoverGraceMs = deps.expiredRenewGraceMs ?? DEFAULT_EXPIRED_RENEW_GRACE_MS
|
|
|
|
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(), expiredGraceMs: 0 }
|
|
: 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)
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Expired-leaf recovery. The lapsed cert arrives in the BODY because no TLS terminator will
|
|
* forward an expired client certificate (nginx `optional` → bare 400; `optional_no_ca` tolerates
|
|
* only chain errors, never `X509_V_ERR_CERT_HAS_EXPIRED`). That makes this route the SOLE
|
|
* verifier, so it runs the identical trust pipeline as `/renew` — real X.509 path validation to
|
|
* the frp-client-CA anchors, SPIFFE parse, `notBefore`, registry `active` + account match — and
|
|
* differs ONLY in allowing a bounded overrun on `notAfter`.
|
|
*
|
|
* Possession of the private key is still proven: the CSR is self-signed by it, and the host
|
|
* signer's delegated gate enforces CSR PoP plus `CSR key == registered key`. A replayed cert
|
|
* without the key therefore yields, at most, a certificate the attacker cannot authenticate with.
|
|
* The header channel is deliberately IGNORED here — on this route only the body speaks.
|
|
*/
|
|
app.post('/recover', async (req, reply) => {
|
|
try {
|
|
const body = RecoverBodySchema.parse(req.body)
|
|
const der = certWireToDer(body.cert)
|
|
if (der === null) throw new RenewRejectError(401)
|
|
const identity = parsePresentedCertIdentity(
|
|
der,
|
|
'host',
|
|
deps.hostCaAnchorsDer
|
|
? {
|
|
caAnchorsDer: deps.hostCaAnchorsDer,
|
|
nowMs: Date.now(),
|
|
expiredGraceMs: recoverGraceMs,
|
|
}
|
|
: undefined,
|
|
)
|
|
rateLimiter.check(`recover:${identity.accountId}:${identity.id}`)
|
|
|
|
const host = await deps.hosts.getHostBySubdomain(identity.id)
|
|
if (host === null || host.status === 'revoked' || host.accountId !== identity.accountId) {
|
|
throw new RenewRejectError(403)
|
|
}
|
|
|
|
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(), expiredGraceMs: 0 }
|
|
: 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)
|
|
}
|
|
})
|
|
}
|
|
}
|