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:
3
control-plane/package-lock.json
generated
3
control-plane/package-lock.json
generated
@@ -8,6 +8,9 @@
|
||||
"name": "control-plane",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-ecc": "^2.8.0",
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/asn1-x509": "^2.8.0",
|
||||
"@peculiar/x509": "^2.0.0",
|
||||
"fastify": "^4.28.1",
|
||||
"ioredis": "^5.4.1",
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-ecc": "^2.8.0",
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/asn1-x509": "^2.8.0",
|
||||
"@peculiar/x509": "^2.0.0",
|
||||
"fastify": "^4.28.1",
|
||||
"ioredis": "^5.4.1",
|
||||
|
||||
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
|
||||
|
||||
97
control-plane/test/csr-ec.test.ts
Normal file
97
control-plane/test/csr-ec.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* A1 acceptance — ECDSA-P256 PKCS#10 parse + proof-of-possession (`ca/csr-ec.ts`), mirroring the
|
||||
* Ed25519 `ca/csr.ts` tests. Gate (d): a valid P-256 CSR yields `ok:true` with the correct embedded
|
||||
* pubkey; any tampered / mismatched / non-P256 / malformed CSR yields the UNIFORM `ok:false` result
|
||||
* with an empty embedded pubkey and no distinguishing detail (fail-closed).
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { webcrypto } from 'node:crypto'
|
||||
import { verifyCsrPoPEc, buildCsrEc } from '../src/ca/csr-ec.js'
|
||||
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
/** WebCrypto key pair (the `CryptoKeyPair` global is not in this project's TS lib set). */
|
||||
type KeyPair = { readonly publicKey: CryptoKey; readonly privateKey: CryptoKey }
|
||||
|
||||
describe('verifyCsrPoPEc — gate (d): valid P-256 CSR', () => {
|
||||
test('a valid P-256 CSR → ok:true with the embedded SPKI matching the generating key', async () => {
|
||||
const { der, keys } = await buildCsrEc('CN=alice')
|
||||
const res = await verifyCsrPoPEc(der)
|
||||
expect(res.ok).toBe(true)
|
||||
expect(res.embeddedPubSpki.length).toBeGreaterThan(0)
|
||||
const exported = new Uint8Array(await webcrypto.subtle.exportKey('spki', keys.publicKey))
|
||||
expect(Buffer.from(res.embeddedPubSpki).equals(Buffer.from(exported))).toBe(true)
|
||||
})
|
||||
|
||||
test('the embedded SPKI re-imports as an EC P-256 verifying key', async () => {
|
||||
const { der } = await buildCsrEc('CN=bob')
|
||||
const { embeddedPubSpki } = await verifyCsrPoPEc(der)
|
||||
const key = await webcrypto.subtle.importKey('spki', new Uint8Array(embeddedPubSpki), { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'])
|
||||
expect(key.type).toBe('public')
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyCsrPoPEc — fail-closed & uniform rejection', () => {
|
||||
test('a tampered CSR (flipped signature byte) → ok:false uniform', async () => {
|
||||
const { der } = await buildCsrEc('CN=carol')
|
||||
const tampered = new Uint8Array(der)
|
||||
tampered[tampered.length - 5]! ^= 0xff
|
||||
const res = await verifyCsrPoPEc(tampered)
|
||||
expect(res).toEqual({ ok: false, embeddedPubSpki: new Uint8Array(0) })
|
||||
})
|
||||
|
||||
test('a CSR whose embedded key was swapped (PoP mismatch) → ok:false uniform', async () => {
|
||||
// Splice: take request-A's body but leave request-B's signature by concatenating mismatched
|
||||
// parts is fragile; instead flip several bytes inside the public-key region to break PoP.
|
||||
const { der } = await buildCsrEc('CN=dave')
|
||||
const mangled = new Uint8Array(der)
|
||||
// Corrupt a byte early in the structure (subject/pubkey area) so the self-signature no longer
|
||||
// matches the tbs — still parseable DER shape but PoP fails.
|
||||
mangled[25]! ^= 0xff
|
||||
const res = await verifyCsrPoPEc(mangled)
|
||||
expect(res).toEqual({ ok: false, embeddedPubSpki: new Uint8Array(0) })
|
||||
})
|
||||
|
||||
test('malformed / non-DER garbage → ok:false uniform (no throw)', async () => {
|
||||
const res = await verifyCsrPoPEc(Uint8Array.from([1, 2, 3, 4, 5]))
|
||||
expect(res).toEqual({ ok: false, embeddedPubSpki: new Uint8Array(0) })
|
||||
})
|
||||
|
||||
test('empty input → ok:false uniform', async () => {
|
||||
const res = await verifyCsrPoPEc(new Uint8Array(0))
|
||||
expect(res).toEqual({ ok: false, embeddedPubSpki: new Uint8Array(0) })
|
||||
})
|
||||
|
||||
test('a valid non-P256 CSR (Ed25519 key) → ok:false uniform (curve enforced)', async () => {
|
||||
const keys = (await webcrypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as KeyPair
|
||||
const csr = await x509.Pkcs10CertificateRequestGenerator.create({
|
||||
name: 'CN=ed-device',
|
||||
keys,
|
||||
signingAlgorithm: { name: 'Ed25519' },
|
||||
})
|
||||
const res = await verifyCsrPoPEc(new Uint8Array(csr.rawData))
|
||||
expect(res).toEqual({ ok: false, embeddedPubSpki: new Uint8Array(0) })
|
||||
})
|
||||
|
||||
test('a valid P-384 CSR → ok:false uniform (only P-256 accepted)', async () => {
|
||||
const keys = (await webcrypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-384' }, true, ['sign', 'verify'])) as unknown as KeyPair
|
||||
const csr = await x509.Pkcs10CertificateRequestGenerator.create({
|
||||
name: 'CN=p384-device',
|
||||
keys,
|
||||
signingAlgorithm: { name: 'ECDSA', hash: 'SHA-384' },
|
||||
})
|
||||
const res = await verifyCsrPoPEc(new Uint8Array(csr.rawData))
|
||||
expect(res).toEqual({ ok: false, embeddedPubSpki: new Uint8Array(0) })
|
||||
})
|
||||
|
||||
test('CP3: each rejection returns a FRESH failure object (no shared module-level singleton)', async () => {
|
||||
const a = await verifyCsrPoPEc(new Uint8Array(0))
|
||||
const b = await verifyCsrPoPEc(new Uint8Array(0))
|
||||
expect(a).toEqual({ ok: false, embeddedPubSpki: new Uint8Array(0) })
|
||||
// Distinct instances (result AND its embedded array) so one caller can never mutate another's.
|
||||
expect(a).not.toBe(b)
|
||||
expect(a.embeddedPubSpki).not.toBe(b.embeddedPubSpki)
|
||||
})
|
||||
})
|
||||
260
control-plane/test/device-enroll.test.ts
Normal file
260
control-plane/test/device-enroll.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* A4 — POST /device/enroll + /device/attest/challenge (fastify inject, mirrors test/api.test.ts).
|
||||
* Proves: a valid device:enroll bearer + valid P-256 CSR → 201 with a real X.509 leaf; missing /
|
||||
* invalid / wrong-right bearers → 401/403 (uniform); over-cap → 429; a malformed CSR → uniform
|
||||
* reject; the attest challenge stub returns a bound challenge. The bearer is verified through the
|
||||
* SAME injected `CapabilityVerifier` seam the admin API uses (boot/verifier.ts in production).
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
import { describe, test, expect, beforeEach } from 'vitest'
|
||||
import Fastify, { type FastifyInstance } from 'fastify'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { webcrypto } from 'node:crypto'
|
||||
import type { CapabilityToken, CapabilityRight } from 'relay-contracts'
|
||||
import type { CapabilityVerifier } from '../src/api/authz.js'
|
||||
import { buildCsrEc } from '../src/ca/csr-ec.js'
|
||||
import { inProcessP256CaSigner } from '../src/boot/ca-wiring.js'
|
||||
import { createDeviceLeafSigner } from '../src/ca/device-issue.js'
|
||||
import { createDeviceRegistry, createMemoryDeviceStore } from '../src/registry/devices.js'
|
||||
import {
|
||||
buildDeviceEnrollRouter,
|
||||
type DeviceEnrollDeps,
|
||||
type SubdomainOwnershipResolver,
|
||||
} from '../src/api/device-enroll.js'
|
||||
import { DEVICE_ENROLL_AUD } from '../src/auth/session.js'
|
||||
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
|
||||
const ACCOUNT_B = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
// Fake verifier mirroring api.test.ts: 'enrollA'/'enrollB'→enroll right; 'attachA'→attach only; else reject.
|
||||
const verifier: CapabilityVerifier = {
|
||||
async verify(raw, expectedAud, now): Promise<CapabilityToken> {
|
||||
const base = { aud: expectedAud, host: 'x', iat: now, exp: now + 600, jti: `jti-${raw}` }
|
||||
if (raw === 'enrollA') return { ...base, sub: ACCOUNT_A, rights: ['enroll'] as CapabilityRight[] }
|
||||
if (raw === 'enrollB') return { ...base, sub: ACCOUNT_B, rights: ['enroll'] as CapabilityRight[] }
|
||||
if (raw === 'attachA') return { ...base, sub: ACCOUNT_A, rights: ['attach'] as CapabilityRight[] }
|
||||
throw new Error('invalid token')
|
||||
},
|
||||
}
|
||||
|
||||
// Ownership source of truth (host-onboarding registry in production). ACCOUNT_A owns 'alice' + 'bob';
|
||||
// everything else is unclaimed → deny-by-default.
|
||||
const SUBDOMAIN_OWNERS: Readonly<Record<string, string>> = { alice: ACCOUNT_A, bob: ACCOUNT_A }
|
||||
const ownership: SubdomainOwnershipResolver = {
|
||||
async ownerOfSubdomain(sub) {
|
||||
return SUBDOMAIN_OWNERS[sub] ?? null
|
||||
},
|
||||
}
|
||||
|
||||
async function csrBody(subdomain = 'alice'): Promise<Record<string, unknown>> {
|
||||
const { der } = await buildCsrEc('CN=web-terminal-device')
|
||||
return { csr: Buffer.from(der).toString('base64'), keyAlg: 'ec-p256', subdomain, deviceName: 'iphone' }
|
||||
}
|
||||
|
||||
// The registry and the leaf signer MUST share ONE DeviceStore so the signer's gate finds the
|
||||
// device the route just registered (mirrors host store sharing between registry + createRealLeafSigner).
|
||||
function buildApp(opts: { deviceCap?: number; rateMax?: number } = {}): FastifyInstance {
|
||||
const ca = inProcessP256CaSigner()
|
||||
const store = createMemoryDeviceStore()
|
||||
const deps: DeviceEnrollDeps = {
|
||||
verifier,
|
||||
ownership,
|
||||
devices: createDeviceRegistry({ devices: store, deviceCap: opts.deviceCap ?? 5, rateMax: opts.rateMax ?? 50 }),
|
||||
signer: createDeviceLeafSigner({
|
||||
signer: ca,
|
||||
issuer: 'CN=device-CA',
|
||||
caChainDer: [ca.publicKeyRaw],
|
||||
sanBaseDomain: 'terminal.yaojia.wang',
|
||||
trustDomain: 'example.com',
|
||||
devices: store,
|
||||
}),
|
||||
}
|
||||
const app = Fastify({ logger: false })
|
||||
void app.register(buildDeviceEnrollRouter(deps))
|
||||
return app
|
||||
}
|
||||
|
||||
let app: FastifyInstance
|
||||
beforeEach(async () => {
|
||||
app = buildApp()
|
||||
await app.ready()
|
||||
})
|
||||
|
||||
describe('A4 POST /device/enroll', () => {
|
||||
test('valid enroll bearer + valid P-256 CSR → 201 with a real cert', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
headers: { authorization: 'Bearer enrollA' },
|
||||
payload: await csrBody(),
|
||||
})
|
||||
expect(res.statusCode).toBe(201)
|
||||
const body = JSON.parse(res.body)
|
||||
expect(body.deviceId.length).toBeGreaterThan(0)
|
||||
expect(Array.isArray(body.caChain)).toBe(true)
|
||||
expect(typeof body.notAfter).toBe('string')
|
||||
expect(typeof body.renewAfter).toBe('string')
|
||||
// the returned cert is a real, re-parseable X.509 leaf
|
||||
const der = new Uint8Array(Buffer.from(body.cert, 'base64'))
|
||||
const leaf = new x509.X509Certificate(der)
|
||||
const san = leaf.getExtension(x509.SubjectAlternativeNameExtension)
|
||||
expect(san!.names.toJSON()).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' })
|
||||
})
|
||||
|
||||
test('missing bearer → 401', async () => {
|
||||
const res = await app.inject({ method: 'POST', url: '/device/enroll', payload: await csrBody() })
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
test('invalid bearer → 401', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
headers: { authorization: 'Bearer bogus' },
|
||||
payload: await csrBody(),
|
||||
})
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
test('wrong-right bearer (attach, not enroll) → 403', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
headers: { authorization: 'Bearer attachA' },
|
||||
payload: await csrBody(),
|
||||
})
|
||||
expect(res.statusCode).toBe(403)
|
||||
})
|
||||
|
||||
test('malformed body → 400 (Zod at the boundary)', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
headers: { authorization: 'Bearer enrollA' },
|
||||
payload: { csr: '', keyAlg: 'rsa', subdomain: 'alice' },
|
||||
})
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
test('malformed CSR (valid shape, bad DER) → uniform reject 400', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
headers: { authorization: 'Bearer enrollA' },
|
||||
payload: { csr: Buffer.from([0, 1, 2, 3]).toString('base64'), keyAlg: 'ec-p256', subdomain: 'alice', deviceName: 'x' },
|
||||
})
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
test('over per-account device cap → 429', async () => {
|
||||
const capped = buildApp({ deviceCap: 1, rateMax: 50 })
|
||||
await capped.ready()
|
||||
const ok = await capped.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
headers: { authorization: 'Bearer enrollA' },
|
||||
payload: await csrBody('alice'),
|
||||
})
|
||||
expect(ok.statusCode).toBe(201)
|
||||
const over = await capped.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
headers: { authorization: 'Bearer enrollA' },
|
||||
payload: await csrBody('bob'),
|
||||
})
|
||||
expect(over.statusCode).toBe(429)
|
||||
})
|
||||
|
||||
test('over per-account rate-limit → 429', async () => {
|
||||
const limited = buildApp({ deviceCap: 50, rateMax: 1 })
|
||||
await limited.ready()
|
||||
const first = await limited.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
headers: { authorization: 'Bearer enrollA' },
|
||||
payload: await csrBody('alice'),
|
||||
})
|
||||
expect(first.statusCode).toBe(201)
|
||||
const second = await limited.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
headers: { authorization: 'Bearer enrollA' },
|
||||
payload: await csrBody('bob'),
|
||||
})
|
||||
expect(second.statusCode).toBe(429)
|
||||
})
|
||||
|
||||
// ── Tenant-isolation gate (FIX C-native-3 / H-native-4) ──────────────────────────────────────────
|
||||
test('account B requesting account A\'s subdomain → 403, no cert issued', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
headers: { authorization: 'Bearer enrollB' }, // valid enroll bearer for a DIFFERENT account
|
||||
payload: await csrBody('alice'), // owned by ACCOUNT_A
|
||||
})
|
||||
expect(res.statusCode).toBe(403)
|
||||
expect(JSON.parse(res.body).cert).toBeUndefined() // rejected before registration + issuance
|
||||
})
|
||||
|
||||
test('unclaimed subdomain → 403 (uniform with foreign-owned: no existence oracle)', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
headers: { authorization: 'Bearer enrollA' },
|
||||
payload: await csrBody('nobody'), // ACCOUNT_A does not own it
|
||||
})
|
||||
expect(res.statusCode).toBe(403)
|
||||
})
|
||||
|
||||
test('reserved infra label (admin) → 400, never reaches a certificate SAN', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
headers: { authorization: 'Bearer enrollA' },
|
||||
payload: await csrBody('admin'),
|
||||
})
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
test('subdomain that normalizes to an invalid label → 400', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
headers: { authorization: 'Bearer enrollA' },
|
||||
payload: await csrBody('___'), // strips to empty → not a valid RFC-1123 label
|
||||
})
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
test('account A\'s own subdomain still enrolls (ownership gate does not affect the owner)', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
headers: { authorization: 'Bearer enrollA' },
|
||||
payload: await csrBody('alice'),
|
||||
})
|
||||
expect(res.statusCode).toBe(201)
|
||||
})
|
||||
})
|
||||
|
||||
describe('A4 POST /device/attest/challenge (stub)', () => {
|
||||
test('valid bearer → 200 with a challenge + expires_in 120', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/device/attest/challenge',
|
||||
headers: { authorization: 'Bearer enrollA' },
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = JSON.parse(res.body)
|
||||
expect(typeof body.challenge).toBe('string')
|
||||
expect(body.challenge.length).toBeGreaterThan(0)
|
||||
expect(body.expires_in).toBe(120)
|
||||
})
|
||||
|
||||
test('missing bearer → 401', async () => {
|
||||
const res = await app.inject({ method: 'POST', url: '/device/attest/challenge' })
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
})
|
||||
133
control-plane/test/device-issue.test.ts
Normal file
133
control-plane/test/device-issue.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* A4 (FIX C-2) — P-256 device leaf issuance off the device-CA via `assembleCertificate`. Proves the
|
||||
* leaf re-parses + verifies against the device-CA public key; carries SAN dNSName `<sub>.terminal.yaojia.wang`
|
||||
* + the device SPIFFE URI (`.../device/<did>`, parseable by relay-auth as kind 'device'); is
|
||||
* clientAuth / CA:false; and that the registry gate rejects uniformly (unknown / revoked / pubkey mismatch).
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { webcrypto } from 'node:crypto'
|
||||
import { parseSpiffeId } from 'relay-auth/src/agent/spiffe.js'
|
||||
import { buildCsrEc } from '../src/ca/csr-ec.js'
|
||||
import { inProcessP256CaSigner } from '../src/boot/ca-wiring.js'
|
||||
import { createDeviceLeafSigner, DeviceLeafSignError } from '../src/ca/device-issue.js'
|
||||
import { createMemoryDeviceStore, type DeviceRecord } from '../src/registry/devices.js'
|
||||
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
const SAN_BASE = 'terminal.yaojia.wang'
|
||||
const TRUST_DOMAIN = 'example.com'
|
||||
const ACCOUNT_A = 'a1'
|
||||
|
||||
async function spkiOf(pub: CryptoKey): Promise<Uint8Array> {
|
||||
return new Uint8Array(await webcrypto.subtle.exportKey('spki', pub))
|
||||
}
|
||||
|
||||
function record(overrides: Partial<DeviceRecord>): DeviceRecord {
|
||||
const now = Date.now()
|
||||
return {
|
||||
deviceId: 'dev-1',
|
||||
accountId: ACCOUNT_A,
|
||||
subdomainScope: 'alice',
|
||||
ecPubkeySpki: new Uint8Array([1, 2, 3]),
|
||||
serial: '0102030405060708090a0b0c0d0e0f10',
|
||||
status: 'active',
|
||||
notAfter: new Date(now + 24 * 60 * 60 * 1000).toISOString(),
|
||||
attestationLevel: 'none',
|
||||
createdAt: new Date(now).toISOString(),
|
||||
revokedAt: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const ca = inProcessP256CaSigner()
|
||||
const store = createMemoryDeviceStore()
|
||||
const signer = createDeviceLeafSigner({
|
||||
signer: ca,
|
||||
issuer: 'CN=device-CA',
|
||||
caChainDer: [ca.publicKeyRaw],
|
||||
sanBaseDomain: SAN_BASE,
|
||||
trustDomain: TRUST_DOMAIN,
|
||||
devices: store,
|
||||
})
|
||||
const { der: csr, keys } = await buildCsrEc('CN=web-terminal-device')
|
||||
const embeddedSpki = await spkiOf(keys.publicKey)
|
||||
return { ca, store, signer, csr, embeddedSpki }
|
||||
}
|
||||
|
||||
async function importP256Public(spkiDer: Uint8Array): Promise<CryptoKey> {
|
||||
return webcrypto.subtle.importKey('spki', new Uint8Array(spkiDer), { name: 'ECDSA', namedCurve: 'P-256' }, true, [
|
||||
'verify',
|
||||
])
|
||||
}
|
||||
|
||||
describe('A4 device-issue — real P-256 device leaf', () => {
|
||||
test('leaf re-parses and verifies against the device-CA public key', async () => {
|
||||
const { ca, store, signer, csr, embeddedSpki } = await fixture()
|
||||
await store.insert(record({ deviceId: 'dev-1', ecPubkeySpki: embeddedSpki }))
|
||||
const { cert } = await signer.signDeviceLeaf('dev-1', csr)
|
||||
const leaf = new x509.X509Certificate(cert)
|
||||
expect(() => new x509.X509Certificate(cert)).not.toThrow()
|
||||
const caPub = await importP256Public(ca.publicKeyRaw)
|
||||
expect(await leaf.verify({ publicKey: caPub, signatureOnly: true })).toBe(true)
|
||||
})
|
||||
|
||||
test('SAN carries dNSName <sub>.terminal.yaojia.wang + the device SPIFFE URI', async () => {
|
||||
const { store, signer, csr, embeddedSpki } = await fixture()
|
||||
await store.insert(record({ deviceId: 'dev-1', accountId: ACCOUNT_A, subdomainScope: 'alice', ecPubkeySpki: embeddedSpki }))
|
||||
const { cert } = await signer.signDeviceLeaf('dev-1', csr)
|
||||
const san = new x509.X509Certificate(cert).getExtension(x509.SubjectAlternativeNameExtension)
|
||||
const names = san!.names.toJSON()
|
||||
expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' })
|
||||
const uri = names.find((n) => n.type === 'url')!.value
|
||||
const parsed = parseSpiffeId(uri)
|
||||
expect(parsed).toEqual({ accountId: ACCOUNT_A, kind: 'device', id: 'dev-1' })
|
||||
})
|
||||
|
||||
test('leaf is EKU clientAuth and CA:false', async () => {
|
||||
const { store, signer, csr, embeddedSpki } = await fixture()
|
||||
await store.insert(record({ deviceId: 'dev-1', ecPubkeySpki: embeddedSpki }))
|
||||
const { cert } = await signer.signDeviceLeaf('dev-1', csr)
|
||||
const leaf = new x509.X509Certificate(cert)
|
||||
const bc = leaf.getExtension(x509.BasicConstraintsExtension)
|
||||
expect(bc!.ca).toBe(false)
|
||||
const eku = leaf.getExtension(x509.ExtendedKeyUsageExtension)
|
||||
expect(eku!.usages).toContain(x509.ExtendedKeyUsage.clientAuth)
|
||||
})
|
||||
|
||||
test('result reports serial + validity from the gated record', async () => {
|
||||
const { store, signer, csr, embeddedSpki } = await fixture()
|
||||
const rec = record({ deviceId: 'dev-1', ecPubkeySpki: embeddedSpki })
|
||||
await store.insert(rec)
|
||||
const res = await signer.signDeviceLeaf('dev-1', csr)
|
||||
expect(res.serial).toBe(rec.serial)
|
||||
expect(res.notAfter.toISOString()).toBe(rec.notAfter)
|
||||
expect(res.notBefore.getTime()).toBeLessThan(Date.now())
|
||||
})
|
||||
|
||||
test('gate rejects an UNKNOWN device uniformly', async () => {
|
||||
const { signer, csr } = await fixture()
|
||||
await expect(signer.signDeviceLeaf('ghost', csr)).rejects.toBeInstanceOf(DeviceLeafSignError)
|
||||
})
|
||||
|
||||
test('gate rejects a REVOKED device uniformly', async () => {
|
||||
const { store, signer, csr, embeddedSpki } = await fixture()
|
||||
await store.insert(record({ deviceId: 'dev-1', status: 'revoked', revokedAt: new Date().toISOString(), ecPubkeySpki: embeddedSpki }))
|
||||
await expect(signer.signDeviceLeaf('dev-1', csr)).rejects.toBeInstanceOf(DeviceLeafSignError)
|
||||
})
|
||||
|
||||
test('gate rejects a pubkey MISMATCH (CSR key ≠ registered key) uniformly', async () => {
|
||||
const { store, signer, csr } = await fixture()
|
||||
// register with a DIFFERENT pubkey than the CSR embeds
|
||||
await store.insert(record({ deviceId: 'dev-1', ecPubkeySpki: new Uint8Array([7, 7, 7, 7]) }))
|
||||
await expect(signer.signDeviceLeaf('dev-1', csr)).rejects.toBeInstanceOf(DeviceLeafSignError)
|
||||
})
|
||||
|
||||
test('gate rejects a malformed CSR uniformly', async () => {
|
||||
const { store, signer, embeddedSpki } = await fixture()
|
||||
await store.insert(record({ deviceId: 'dev-1', ecPubkeySpki: embeddedSpki }))
|
||||
await expect(signer.signDeviceLeaf('dev-1', new Uint8Array([0, 1, 2, 3]))).rejects.toBeInstanceOf(DeviceLeafSignError)
|
||||
})
|
||||
})
|
||||
255
control-plane/test/frpclient-issue.test.ts
Normal file
255
control-plane/test/frpclient-issue.test.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* B1 acceptance (FIX H-host-2, H-host-4) — the P-256 HOST frp-client leaf signer. Proves an issued
|
||||
* leaf is a REAL, tool-parseable, signature-verifiable X.509 v3 cert off the P-256 frp-client-CA,
|
||||
* carrying the dNSName ENFORCEMENT key + the host SPIFFE-ID (which relay-auth's parser accepts),
|
||||
* EKU clientAuth + CA:false; that the registry gate rejects unregistered / revoked / pubkey-mismatch
|
||||
* UNIFORMLY without ever invoking the CA signer; and that a P-256 CSR built by the AGENT's own
|
||||
* `enroll/csr.ts` passes `verifyCsrPoPEc` and flows its embedded pubkey through to issuance.
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { AsnConvert } from '@peculiar/asn1-schema'
|
||||
import { Certificate } from '@peculiar/asn1-x509'
|
||||
import { webcrypto, generateKeyPairSync, randomUUID } from 'node:crypto'
|
||||
import { parseSpiffeId, spiffeIdFor } from 'relay-auth/src/agent/spiffe.js'
|
||||
import { createMemoryStores } from '../src/store/memory.js'
|
||||
import { createHostRegistry } from '../src/registry/hosts.js'
|
||||
import { fingerprint } from '../src/ca/fingerprint.js'
|
||||
import { assembleCertificate } from '../src/ca/x509-assembler.js'
|
||||
import { buildCsrEc, verifyCsrPoPEc } from '../src/ca/csr-ec.js'
|
||||
import { inProcessP256CaSigner, type CaSigner } from '../src/boot/ca-wiring.js'
|
||||
import { createFrpClientLeafSigner } from '../src/ca/frpclient-issue.js'
|
||||
import { LeafSignError } from '../src/ca/sign.js'
|
||||
// Cross-track proof (vi): drive the ACTUAL agent-side P-256 identity + CSR encoder.
|
||||
import { generateP256Identity } from '../../agent/src/keys/identity.js'
|
||||
import { buildCsr as buildAgentCsr } from '../../agent/src/enroll/csr.js'
|
||||
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const TRUST_DOMAIN = 'terminal.yaojia.wang'
|
||||
const DNS_ZONE = 'terminal.yaojia.wang'
|
||||
|
||||
interface FrpClientCa {
|
||||
readonly caSigner: CaSigner
|
||||
readonly caCert: x509.X509Certificate
|
||||
readonly caDer: Uint8Array
|
||||
}
|
||||
|
||||
/** Self-signed P-256 `frp-client-CA` (subject key == signer key) so leaves chain to a real CA. */
|
||||
async function makeFrpClientCa(): Promise<FrpClientCa> {
|
||||
const caKeys = generateKeyPairSync('ec', { namedCurve: 'P-256' })
|
||||
const caSpki = new Uint8Array(caKeys.publicKey.export({ format: 'der', type: 'spki' }))
|
||||
const caSigner = inProcessP256CaSigner(caKeys.privateKey)
|
||||
const now = Date.now()
|
||||
const caDer = await assembleCertificate({
|
||||
subjectPublicKey: caSpki,
|
||||
subject: 'CN=frp-client-CA',
|
||||
issuer: 'CN=frp-client-CA',
|
||||
serialNumber: Uint8Array.from([0x01]),
|
||||
notBefore: new Date(now - DAY_MS),
|
||||
notAfter: new Date(now + 365 * DAY_MS),
|
||||
extensions: [
|
||||
new x509.BasicConstraintsExtension(true, undefined, true),
|
||||
new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true),
|
||||
],
|
||||
signer: caSigner,
|
||||
sigAlg: 'ecdsa-p256',
|
||||
})
|
||||
return { caSigner, caCert: new x509.X509Certificate(caDer), caDer }
|
||||
}
|
||||
|
||||
async function importP256Public(spkiDer: Uint8Array): Promise<CryptoKey> {
|
||||
return webcrypto.subtle.importKey('spki', new Uint8Array(spkiDer), { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'])
|
||||
}
|
||||
|
||||
async function spkiOf(pub: CryptoKey): Promise<Uint8Array> {
|
||||
return new Uint8Array(await webcrypto.subtle.exportKey('spki', pub))
|
||||
}
|
||||
|
||||
/** A memory-store host registry with a P-256 host bound under `subdomain`, plus its CSR + SPKI. */
|
||||
async function boundHost(subdomain = 'alice') {
|
||||
const stores = createMemoryStores()
|
||||
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||
const { der: csr, keys } = await buildCsrEc(`CN=${subdomain}`)
|
||||
const spki = await spkiOf(keys.publicKey)
|
||||
const accountId = randomUUID()
|
||||
const host = await hosts.bindHost({ accountId, subdomain, agentPubkey: spki, enrollFpr: fingerprint(spki) })
|
||||
return { stores, hosts, host, csr, spki, accountId }
|
||||
}
|
||||
|
||||
function makeSigner(ca: FrpClientCa, stores: ReturnType<typeof createMemoryStores>) {
|
||||
return createFrpClientLeafSigner({
|
||||
hosts: stores.hosts,
|
||||
signer: ca.caSigner,
|
||||
issuerName: ca.caCert.subjectName,
|
||||
caChainDer: [ca.caDer],
|
||||
trustDomain: TRUST_DOMAIN,
|
||||
dnsZone: DNS_ZONE,
|
||||
})
|
||||
}
|
||||
|
||||
/** Assert a promise rejects with a `LeafSignError` and return it so the caller can check `.code`. */
|
||||
async function expectLeafSignError(p: Promise<unknown>): Promise<LeafSignError> {
|
||||
const err = await p.then(
|
||||
() => {
|
||||
throw new Error('expected the promise to reject with LeafSignError')
|
||||
},
|
||||
(e: unknown) => e,
|
||||
)
|
||||
expect(err).toBeInstanceOf(LeafSignError)
|
||||
return err as LeafSignError
|
||||
}
|
||||
|
||||
describe('frpclient-issue — (i) issued leaf re-parses as X.509 & (ii) chains to the frp-client-CA', () => {
|
||||
test('leaf re-parses via new x509.X509Certificate and its signature verifies against the CA pubkey', async () => {
|
||||
const ca = await makeFrpClientCa()
|
||||
const { stores, host, csr, spki } = await boundHost()
|
||||
const { cert, caChain } = await makeSigner(ca, stores).signHostLeaf(host.hostId, spki, csr)
|
||||
|
||||
expect(() => new x509.X509Certificate(cert)).not.toThrow()
|
||||
const leaf = new x509.X509Certificate(cert)
|
||||
expect(leaf.issuer).toBe(ca.caCert.subject) // issuer DN == CA subject DN (chain sanity)
|
||||
const caPub = await importP256Public(ca.caSigner.publicKeyRaw)
|
||||
expect(await leaf.verify({ publicKey: caPub, signatureOnly: true })).toBe(true)
|
||||
// negative: does NOT verify under a different CA key
|
||||
const otherCa = await importP256Public(inProcessP256CaSigner().publicKeyRaw)
|
||||
expect(await leaf.verify({ publicKey: otherCa, signatureOnly: true })).toBe(false)
|
||||
expect(caChain).toEqual([ca.caDer])
|
||||
})
|
||||
|
||||
test('subject key is the enrolled host P-256 key (SPKI byte-equal)', async () => {
|
||||
const ca = await makeFrpClientCa()
|
||||
const { stores, host, csr, spki } = await boundHost()
|
||||
const { cert } = await makeSigner(ca, stores).signHostLeaf(host.hostId, spki, csr)
|
||||
const leaf = new x509.X509Certificate(cert)
|
||||
expect(Buffer.from(leaf.publicKey.rawData).equals(Buffer.from(spki))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('frpclient-issue — (iii) SAN carries dNSName + host SPIFFE URI', () => {
|
||||
test("SAN = { dNSName '<sub>.terminal.yaojia.wang', URI host-SPIFFE } and parseSpiffeId accepts it as kind host", async () => {
|
||||
const ca = await makeFrpClientCa()
|
||||
const { stores, host, csr, spki, accountId } = await boundHost('alice')
|
||||
const { cert } = await makeSigner(ca, stores).signHostLeaf(host.hostId, spki, csr)
|
||||
const leaf = new x509.X509Certificate(cert)
|
||||
const san = leaf.getExtension(x509.SubjectAlternativeNameExtension)
|
||||
const names = san!.names.toJSON()
|
||||
|
||||
expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' })
|
||||
const uri = names.find((n) => n.type === 'url')!.value
|
||||
expect(uri).toBe(spiffeIdFor(accountId, 'alice', TRUST_DOMAIN, 'host'))
|
||||
// relay-auth's OWN parser accepts the URI as a host identity (never drifts from the verifier).
|
||||
expect(parseSpiffeId(uri)).toEqual({ accountId, id: 'alice', kind: 'host' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('frpclient-issue — (iv) EKU clientAuth + CA:false + KeyUsage digitalSignature', () => {
|
||||
test('leaf is a client-auth end-entity cert', async () => {
|
||||
const ca = await makeFrpClientCa()
|
||||
const { stores, host, csr, spki } = await boundHost()
|
||||
const { cert } = await makeSigner(ca, stores).signHostLeaf(host.hostId, spki, csr)
|
||||
const leaf = new x509.X509Certificate(cert)
|
||||
|
||||
const bc = leaf.getExtension(x509.BasicConstraintsExtension)
|
||||
expect(bc!.ca).toBe(false)
|
||||
const eku = leaf.getExtension(x509.ExtendedKeyUsageExtension)
|
||||
expect(eku!.usages).toContain(x509.ExtendedKeyUsage.clientAuth)
|
||||
const ku = leaf.getExtension(x509.KeyUsagesExtension)
|
||||
expect(ku!.usages & x509.KeyUsageFlags.digitalSignature).not.toBe(0)
|
||||
// signatureAlgorithm is ecdsa-with-SHA256 (a P-256 leaf, not Ed25519).
|
||||
const parsed = AsnConvert.parse(cert.buffer.slice(cert.byteOffset, cert.byteOffset + cert.byteLength) as ArrayBuffer, Certificate)
|
||||
expect(parsed.signatureAlgorithm.algorithm).toBe('1.2.840.10045.4.3.2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('frpclient-issue — (v) registry gate rejects uniformly, CA signer never invoked', () => {
|
||||
test('unregistered host → LeafSignError(not_registered), sign() never called', async () => {
|
||||
const ca = await makeFrpClientCa()
|
||||
const signSpy = vi.spyOn(ca.caSigner, 'sign')
|
||||
const { stores, csr, spki } = await boundHost()
|
||||
const err = await expectLeafSignError(makeSigner(ca, stores).signHostLeaf(randomUUID(), spki, csr))
|
||||
expect(err.code).toBe('not_registered')
|
||||
expect(signSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('revoked host → LeafSignError(not_registered), sign() never called', async () => {
|
||||
const ca = await makeFrpClientCa()
|
||||
const signSpy = vi.spyOn(ca.caSigner, 'sign')
|
||||
const { stores, hosts, host, csr, spki } = await boundHost()
|
||||
await hosts.setHostStatus(host.hostId, 'revoked')
|
||||
const err = await expectLeafSignError(makeSigner(ca, stores).signHostLeaf(host.hostId, spki, csr))
|
||||
expect(err.code).toBe('not_registered')
|
||||
expect(signSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('registry pubkey mismatch (present a different key than registered) → not_registered', async () => {
|
||||
const ca = await makeFrpClientCa()
|
||||
const signSpy = vi.spyOn(ca.caSigner, 'sign')
|
||||
const { stores, host } = await boundHost() // host registered under key A
|
||||
// A DIFFERENT P-256 key B — CSR PoP + substitution checks pass (embedded == presented), but the
|
||||
// registry stored key A, so step 3 rejects.
|
||||
const { der: csrB, keys: keysB } = await buildCsrEc('CN=alice')
|
||||
const spkiB = await spkiOf(keysB.publicKey)
|
||||
const err = await expectLeafSignError(makeSigner(ca, stores).signHostLeaf(host.hostId, spkiB, csrB))
|
||||
expect(err.code).toBe('not_registered')
|
||||
expect(signSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('bad CSR proof-of-possession → LeafSignError(csr_rejected), sign() never called', async () => {
|
||||
const ca = await makeFrpClientCa()
|
||||
const signSpy = vi.spyOn(ca.caSigner, 'sign')
|
||||
const { stores, host, csr, spki } = await boundHost()
|
||||
const forged = Uint8Array.from(csr)
|
||||
forged[forged.length - 1] = forged[forged.length - 1]! ^ 0xff // corrupt the signature
|
||||
const err = await expectLeafSignError(makeSigner(ca, stores).signHostLeaf(host.hostId, spki, forged))
|
||||
expect(err.code).toBe('csr_rejected')
|
||||
expect(signSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('all rejects share the SAME uniform message (never leaks which check failed)', async () => {
|
||||
const ca = await makeFrpClientCa()
|
||||
const { stores, hosts, host, csr, spki } = await boundHost()
|
||||
const signer = makeSigner(ca, stores)
|
||||
const unregistered = await expectLeafSignError(signer.signHostLeaf(randomUUID(), spki, csr))
|
||||
await hosts.setHostStatus(host.hostId, 'revoked')
|
||||
const revoked = await expectLeafSignError(signer.signHostLeaf(host.hostId, spki, csr))
|
||||
expect(unregistered.message).toBe(revoked.message)
|
||||
expect(unregistered.message).toBe('leaf signing refused')
|
||||
})
|
||||
})
|
||||
|
||||
describe('frpclient-issue — (vi) an AGENT-built P-256 CSR passes verifyCsrPoPEc & issues', () => {
|
||||
test('agent enroll/csr.ts CSR: verifyCsrPoPEc ok + embedded pubkey flows into the leaf', async () => {
|
||||
// Drive the REAL agent-side encoder (not the control-plane test helper).
|
||||
const id = generateP256Identity()
|
||||
const agentCsrPem = buildAgentCsr(id, 'alice.terminal.yaojia.wang')
|
||||
const agentCsrDer = new Uint8Array(
|
||||
Buffer.from(agentCsrPem.replace(/-----[A-Z ]+-----/g, '').replace(/\s+/g, ''), 'base64'),
|
||||
)
|
||||
|
||||
// (a) the control-plane P-256 PoP verifier accepts the agent's hand-rolled CSR.
|
||||
const pop = await verifyCsrPoPEc(agentCsrDer)
|
||||
expect(pop.ok).toBe(true)
|
||||
// its embedded pubkey is exactly the agent identity's EC SPKI.
|
||||
expect(Buffer.from(pop.embeddedPubSpki).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||
|
||||
// (b) that same pubkey flows through issuance: register it, sign, and the leaf carries it.
|
||||
const ca = await makeFrpClientCa()
|
||||
const stores = createMemoryStores()
|
||||
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||
const accountId = randomUUID()
|
||||
const host = await hosts.bindHost({
|
||||
accountId,
|
||||
subdomain: 'alice',
|
||||
agentPubkey: id.publicKey,
|
||||
enrollFpr: fingerprint(id.publicKey),
|
||||
})
|
||||
const { cert } = await makeSigner(ca, stores).signHostLeaf(host.hostId, id.publicKey, agentCsrDer)
|
||||
const leaf = new x509.X509Certificate(cert)
|
||||
expect(Buffer.from(leaf.publicKey.rawData).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||
const caPub = await importP256Public(ca.caSigner.publicKeyRaw)
|
||||
expect(await leaf.verify({ publicKey: caPub, signatureOnly: true })).toBe(true)
|
||||
})
|
||||
})
|
||||
238
control-plane/test/getcertsub.test.ts
Normal file
238
control-plane/test/getcertsub.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* A3 (FIX C-native-3) — the SINGLE load-bearing tenant-isolation control, unit-tested under Node.
|
||||
*
|
||||
* `deploy/nginx/njs/getCertSub.js` runs inside nginx (njs) via `js_set $cert_sub getCertSub`. This
|
||||
* suite exercises the SAME source under Node against REAL P-256 leaf certs minted by the A1 issuance
|
||||
* primitive (`assembleCertificate` + `inProcessP256CaSigner`) — the exact structure the B1/A4 issuers
|
||||
* stamp — so a parsing regression is caught in CI, not in production where it would be a skeleton-key.
|
||||
*
|
||||
* Two layers, both here:
|
||||
* 1. `extractLeftmostDnsLabel` — the njs SAN extractor: leftmost dNSName label, fail-closed to ''.
|
||||
* 2. `certHostOk` — a JS mirror of the nginx `map "$cert_sub:$ssl_server_name"` PCRE rule, so the
|
||||
* allow/deny semantics (incl. the cross-tenant NEGATIVE case) are asserted deterministically.
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { webcrypto, randomBytes } from 'node:crypto'
|
||||
import certsub from '../../deploy/nginx/njs/getCertSub.js'
|
||||
import { assembleCertificate } from '../src/ca/x509-assembler.js'
|
||||
import { inProcessP256CaSigner } from '../src/boot/ca-wiring.js'
|
||||
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
const { extractLeftmostDnsLabel, getCertSub } = certsub
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/** WebCrypto key pair (the `CryptoKeyPair` global is not in this project's TS lib set). */
|
||||
type KeyPair = { readonly publicKey: CryptoKey; readonly privateKey: CryptoKey }
|
||||
|
||||
async function genP256(): Promise<KeyPair> {
|
||||
return (await webcrypto.subtle.generateKey(
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
true,
|
||||
['sign', 'verify'],
|
||||
)) as unknown as KeyPair
|
||||
}
|
||||
|
||||
function derToPem(der: Uint8Array, label = 'CERTIFICATE'): string {
|
||||
const b64 = Buffer.from(der).toString('base64').match(/.{1,64}/g)?.join('\n') ?? ''
|
||||
return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----\n`
|
||||
}
|
||||
|
||||
type SanEntry = { readonly type: 'dns' | 'url'; readonly value: string }
|
||||
|
||||
/**
|
||||
* Mint a REAL P-256 leaf PEM off the dev P-256 CA (stand-in for the B1/A4 issuers), carrying exactly
|
||||
* the supplied SAN entries. Empty `san` omits the SAN extension entirely (the no-SAN case).
|
||||
*/
|
||||
async function mintLeafPem(san: readonly SanEntry[]): Promise<string> {
|
||||
const subject = await genP256()
|
||||
const extensions: x509.Extension[] = []
|
||||
if (san.length > 0) {
|
||||
extensions.push(new x509.SubjectAlternativeNameExtension(san as { type: string; value: string }[]))
|
||||
}
|
||||
extensions.push(new x509.BasicConstraintsExtension(false, undefined, true))
|
||||
extensions.push(new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.clientAuth]))
|
||||
const now = Date.now()
|
||||
const der = await assembleCertificate({
|
||||
subjectPublicKey: subject.publicKey,
|
||||
subject: 'CN=leaf',
|
||||
issuer: 'CN=device-CA',
|
||||
serialNumber: randomBytes(16),
|
||||
notBefore: new Date(now - 60_000),
|
||||
notAfter: new Date(now + DAY_MS),
|
||||
extensions,
|
||||
signer: inProcessP256CaSigner(),
|
||||
sigAlg: 'ecdsa-p256',
|
||||
})
|
||||
return derToPem(der)
|
||||
}
|
||||
|
||||
const SPIFFE_ALICE = 'spiffe://terminal.yaojia.wang/account/a1/host/alice'
|
||||
|
||||
describe('extractLeftmostDnsLabel — POSITIVE (real A1-minted leaves, FIX C-native-3)', () => {
|
||||
test("SAN dNSName 'alice.terminal.yaojia.wang' (+ spiffe URI) → 'alice'", async () => {
|
||||
const pem = await mintLeafPem([
|
||||
{ type: 'dns', value: 'alice.terminal.yaojia.wang' },
|
||||
{ type: 'url', value: SPIFFE_ALICE },
|
||||
])
|
||||
expect(extractLeftmostDnsLabel(pem)).toBe('alice')
|
||||
})
|
||||
|
||||
test("SAN dNSName 'bob.terminal.yaojia.wang' → 'bob'", async () => {
|
||||
const pem = await mintLeafPem([{ type: 'dns', value: 'bob.terminal.yaojia.wang' }])
|
||||
expect(extractLeftmostDnsLabel(pem)).toBe('bob')
|
||||
})
|
||||
|
||||
test('picks the dNSName even when a URI SAN is listed FIRST (order-independent)', async () => {
|
||||
const pem = await mintLeafPem([
|
||||
{ type: 'url', value: 'spiffe://terminal.yaojia.wang/account/a1/host/charlie' },
|
||||
{ type: 'dns', value: 'charlie.terminal.yaojia.wang' },
|
||||
])
|
||||
expect(extractLeftmostDnsLabel(pem)).toBe('charlie')
|
||||
})
|
||||
|
||||
test('takes the FIRST dNSName when multiple are present', async () => {
|
||||
const pem = await mintLeafPem([
|
||||
{ type: 'dns', value: 'dave.terminal.yaojia.wang' },
|
||||
{ type: 'dns', value: 'erin.terminal.yaojia.wang' },
|
||||
])
|
||||
expect(extractLeftmostDnsLabel(pem)).toBe('dave')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractLeftmostDnsLabel — FAIL-CLOSED (any parse failure / missing SAN → "")', () => {
|
||||
test('a leaf with ONLY a URI SAN (no dNSName) → ""', async () => {
|
||||
const pem = await mintLeafPem([{ type: 'url', value: SPIFFE_ALICE }])
|
||||
expect(extractLeftmostDnsLabel(pem)).toBe('')
|
||||
})
|
||||
|
||||
test('a leaf with NO SAN extension at all → ""', async () => {
|
||||
const pem = await mintLeafPem([])
|
||||
expect(extractLeftmostDnsLabel(pem)).toBe('')
|
||||
})
|
||||
|
||||
test('empty string → ""', () => {
|
||||
expect(extractLeftmostDnsLabel('')).toBe('')
|
||||
})
|
||||
|
||||
test('non-PEM garbage → ""', () => {
|
||||
expect(extractLeftmostDnsLabel('this is not a certificate')).toBe('')
|
||||
})
|
||||
|
||||
test('valid PEM markers but garbage base64 body → ""', () => {
|
||||
const junk = '-----BEGIN CERTIFICATE-----\nZ m 9 v Y m F y\n-----END CERTIFICATE-----\n'
|
||||
expect(extractLeftmostDnsLabel(junk)).toBe('')
|
||||
})
|
||||
|
||||
test('truncated DER inside valid PEM markers → ""', async () => {
|
||||
const pem = await mintLeafPem([{ type: 'dns', value: 'alice.terminal.yaojia.wang' }])
|
||||
const body = pem.split('\n').slice(1, 3).join('') // keep only the first ~2 base64 lines
|
||||
const truncated = `-----BEGIN CERTIFICATE-----\n${body}\n-----END CERTIFICATE-----\n`
|
||||
expect(extractLeftmostDnsLabel(truncated)).toBe('')
|
||||
})
|
||||
|
||||
test('CP5: a nested-length-inconsistent cert cannot leak a dNSName past its parent bound → ""', () => {
|
||||
// Hand-crafted DER: Certificate→TBS→[3]→Extensions→Extension(SAN)→extnValue OCTET STRING whose
|
||||
// GeneralNames SEQUENCE declares length 6 — overrunning the 2-byte extnValue content — so its
|
||||
// "content" reaches into a dNSName ('evil') that actually sits AFTER the OCTET STRING as a sibling
|
||||
// (but still inside the buffer). Without the parent-bound check readTlv would accept the inflated
|
||||
// GeneralNames and return 'evil' (a cross-tenant skeleton-key); bounding each child to its parent's
|
||||
// contentEnd makes the extractor fail-closed to ''.
|
||||
const der = [
|
||||
0x30, 0x17, // Certificate SEQUENCE, len 23
|
||||
0x30, 0x15, // TBSCertificate SEQUENCE, len 21
|
||||
0xa3, 0x13, // [3] extensions container, len 19
|
||||
0x30, 0x11, // Extensions SEQUENCE, len 17
|
||||
0x30, 0x09, // Extension SEQUENCE, len 9
|
||||
0x06, 0x03, 0x55, 0x1d, 0x11, // OID 2.5.29.17 (subjectAltName)
|
||||
0x04, 0x02, 0x30, 0x06, // extnValue OCTET STRING (len 2) = GeneralNames header claiming len 6
|
||||
0x82, 0x04, 0x65, 0x76, 0x69, 0x6c, // dNSName [2] "evil" — sibling, reached only by the lie
|
||||
]
|
||||
const b64 = Buffer.from(Uint8Array.from(der)).toString('base64')
|
||||
const pem = `-----BEGIN CERTIFICATE-----\n${b64}\n-----END CERTIFICATE-----\n`
|
||||
expect(extractLeftmostDnsLabel(pem)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCertSub — njs entrypoint glue over r.variables.ssl_client_cert', () => {
|
||||
test('reads ssl_client_cert and returns the leftmost label', async () => {
|
||||
const pem = await mintLeafPem([{ type: 'dns', value: 'alice.terminal.yaojia.wang' }])
|
||||
expect(getCertSub({ variables: { ssl_client_cert: pem } })).toBe('alice')
|
||||
})
|
||||
|
||||
test('missing ssl_client_cert (no client cert) → "" (fail-closed → map denies)', () => {
|
||||
expect(getCertSub({ variables: {} })).toBe('')
|
||||
})
|
||||
|
||||
test('tab-mangled PEM (nginx $ssl_client_cert continuation form) still parses', async () => {
|
||||
const pem = await mintLeafPem([{ type: 'dns', value: 'alice.terminal.yaojia.wang' }])
|
||||
// nginx prepends a TAB to every line except the first in $ssl_client_cert.
|
||||
const tabbed = pem
|
||||
.split('\n')
|
||||
.map((line, i) => (i === 0 ? line : `\t${line}`))
|
||||
.join('\n')
|
||||
expect(getCertSub({ variables: { ssl_client_cert: tabbed } })).toBe('alice')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* JS mirror of the nginx map (zone-anchored, self-sufficient):
|
||||
* map "$cert_sub:$ssl_server_name" $cert_host_ok { "~^(?<s>[^:]+):(?P=s)\\.terminal\\.yaojia\\.wang$" 1; default 0; }
|
||||
* PCRE `(?P=s)` is the named backreference; JS spells the same backreference `\k<s>`. Both engines
|
||||
* require: a non-empty leftmost label before ':', then the SAME text after ':' followed by EXACTLY
|
||||
* `.terminal.yaojia.wang` to the end. Anchoring the full zone (not just `<label>.`) means the map does
|
||||
* not rely on the server_name regex / stream SNI routing to constrain the zone. An empty $cert_sub
|
||||
* (fail-closed extractor output) can never satisfy `[^:]+` → always denies.
|
||||
*/
|
||||
const CERT_HOST_RE = /^(?<s>[^:]+):\k<s>\.terminal\.yaojia\.wang$/
|
||||
function certHostOk(certSub: string, serverName: string): 0 | 1 {
|
||||
return CERT_HOST_RE.test(`${certSub}:${serverName}`) ? 1 : 0
|
||||
}
|
||||
|
||||
describe('certHostOk — map "$cert_sub:$ssl_server_name" allow/deny (mirrors nginx PCRE)', () => {
|
||||
test("own host ALLOWS: ('alice','alice.terminal.yaojia.wang') → 1", () => {
|
||||
expect(certHostOk('alice', 'alice.terminal.yaojia.wang')).toBe(1)
|
||||
})
|
||||
|
||||
test("cross-tenant DENIES: ('alice','bob.terminal.yaojia.wang') → 0 [THE NEGATIVE]", () => {
|
||||
expect(certHostOk('alice', 'bob.terminal.yaojia.wang')).toBe(0)
|
||||
})
|
||||
|
||||
test("empty cert_sub DENIES: ('','alice.terminal.yaojia.wang') → 0", () => {
|
||||
expect(certHostOk('', 'alice.terminal.yaojia.wang')).toBe(0)
|
||||
})
|
||||
|
||||
test("prefix attack DENIES: ('alice','alicex.terminal.yaojia.wang') → 0", () => {
|
||||
expect(certHostOk('alice', 'alicex.terminal.yaojia.wang')).toBe(0)
|
||||
})
|
||||
|
||||
test("no-dot server_name DENIES: ('alice','alice') → 0", () => {
|
||||
expect(certHostOk('alice', 'alice')).toBe(0)
|
||||
})
|
||||
|
||||
test("empty server_name DENIES: ('alice','') → 0", () => {
|
||||
expect(certHostOk('alice', '')).toBe(0)
|
||||
})
|
||||
|
||||
test("a second tenant ALLOWS its own host: ('bob','bob.terminal.yaojia.wang') → 1", () => {
|
||||
expect(certHostOk('bob', 'bob.terminal.yaojia.wang')).toBe(1)
|
||||
})
|
||||
|
||||
test("cross-ZONE bypass DENIES: ('alice','alice.evil.com') → 0 [zone-anchor hardening]", () => {
|
||||
// A label-only map (`(?P=s)\.`) would wrongly ALLOW this; the full-zone anchor denies it, so the
|
||||
// map stays sound even if the server_name regex / stream SNI routing were ever loosened.
|
||||
expect(certHostOk('alice', 'alice.evil.com')).toBe(0)
|
||||
expect(certHostOk('alice', 'alice.terminal.yaojia.wang.evil.com')).toBe(0)
|
||||
})
|
||||
|
||||
test('end-to-end: extractor output feeds the map (alice cert on bob host → deny)', async () => {
|
||||
const pem = await mintLeafPem([{ type: 'dns', value: 'alice.terminal.yaojia.wang' }])
|
||||
const sub = extractLeftmostDnsLabel(pem)
|
||||
expect(sub).toBe('alice')
|
||||
expect(certHostOk(sub, 'alice.terminal.yaojia.wang')).toBe(1)
|
||||
expect(certHostOk(sub, 'bob.terminal.yaojia.wang')).toBe(0)
|
||||
})
|
||||
})
|
||||
372
control-plane/test/integration-native.test.ts
Normal file
372
control-plane/test/integration-native.test.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* Native-tunnel wiring E2E (fastify inject against the WIRED control-plane app from `main.ts`).
|
||||
*
|
||||
* Proves the full DEVICE path through the real composition root:
|
||||
* mint a device:enroll token (auth/session, verified by the REAL §4.3 verifier the boot wires) →
|
||||
* POST /device/enroll {csr:<real P-256 CSR>, subdomain:<owned>} → 201 with a cert that RE-PARSES,
|
||||
* CHAINS to the wired dev device-CA, and carries dNSName <sub>.terminal.yaojia.wang → present that
|
||||
* cert to POST /device/:id/renew → 201 fresh cert for the SAME subdomain.
|
||||
* Plus the A4 ownership gate end-to-end: enroll for an UNOWNED (and a foreign-owned) subdomain → 403.
|
||||
*
|
||||
* Also proves the HOST /renew path through the wired app: the initial frp-client leaf is minted via
|
||||
* the WIRED host signer (native host HTTP /enroll is a documented follow-up — the existing pairing
|
||||
* `/enroll` relay route is left untouched), then presented to /renew → 201 for the SAME subdomain.
|
||||
*
|
||||
* The verifier is the SAME seam the admin API uses (`boot/verifier.ts`), keyed off the boot env's
|
||||
* capability pubkey — so the enroll token is minted with the matching private key and travels the
|
||||
* exact verify path production uses.
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
import { describe, test, expect, beforeEach } from 'vitest'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { webcrypto } from 'node:crypto'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { generateEd25519KeyPair, exportEd25519PublicRaw } from 'relay-auth/src/crypto/ed25519.js'
|
||||
import { createMemoryStores } from '../src/store/memory.js'
|
||||
import { createHostRegistry } from '../src/registry/hosts.js'
|
||||
import { createPairingIssuer } from '../src/pairing/issue.js'
|
||||
import { fingerprint } from '../src/ca/fingerprint.js'
|
||||
import { generateEd25519 } from '../src/util/crypto.js'
|
||||
import { buildCsr } from '../src/ca/csr.js'
|
||||
import { buildCsrEc } from '../src/ca/csr-ec.js'
|
||||
import { buildControlPlane, type BuiltControlPlane } from '../src/main.js'
|
||||
import { createCapabilityVerifier } from '../src/boot/verifier.js'
|
||||
import { mintDeviceEnrollToken } from '../src/auth/session.js'
|
||||
import { loadEnv, type ControlPlaneEnv } from '../src/env.js'
|
||||
import { bytesToBase64 } from '../src/util/bytes.js'
|
||||
import type { Stores } from '../src/store/ports.js'
|
||||
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
|
||||
const ACCOUNT_B = '22222222-2222-4222-8222-222222222222'
|
||||
const DNS_ZONE = 'terminal.yaojia.wang'
|
||||
|
||||
let signingKey: CryptoKey
|
||||
let env: ControlPlaneEnv
|
||||
|
||||
beforeEach(async () => {
|
||||
// Ed25519 capability keypair: the raw public key becomes the boot env's verify key, so the
|
||||
// device:enroll token minted with the private key verifies through the wired real §4.3 verifier.
|
||||
const pair = await generateEd25519KeyPair()
|
||||
signingKey = pair.privateKey
|
||||
const rawPub = await exportEd25519PublicRaw(pair.publicKey)
|
||||
env = loadEnv({
|
||||
PG_URL: 'postgres://u:p@localhost:5432/cp',
|
||||
REDIS_URL: 'redis://localhost:6379',
|
||||
CAPABILITY_SIGN_PUBKEY_B64: bytesToBase64(rawPub),
|
||||
CA_INTERMEDIATE_KMS_KEY_REF: 'kms://key/intermediate',
|
||||
CA_INTERMEDIATE_CERT_PATH: '/etc/cp/int.pem',
|
||||
NODE_MTLS_TRUST_BUNDLE_PATH: '/etc/cp/node-ca.pem',
|
||||
RELAY_TRUST_DOMAIN: DNS_ZONE,
|
||||
BASE_DOMAIN: 'term.example.com',
|
||||
})
|
||||
})
|
||||
|
||||
async function buildApp(): Promise<{ app: FastifyInstance; stores: Stores; built: BuiltControlPlane }> {
|
||||
const stores = createMemoryStores()
|
||||
const built = await buildControlPlane(env, { stores, verifier: createCapabilityVerifier() })
|
||||
await built.app.ready()
|
||||
return { app: built.app, stores, built }
|
||||
}
|
||||
|
||||
/** Onboard a host so `accountId` OWNS `subdomain` in the ownership source of truth (registry). */
|
||||
async function ownSubdomain(stores: Stores, accountId: string, subdomain: string): Promise<void> {
|
||||
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||
const { publicKeyRaw } = generateEd25519()
|
||||
await hosts.bindHost({ accountId, subdomain, agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
|
||||
}
|
||||
|
||||
/** Mint a single-use pairing code bound to `accountId` (the enroll gate reads accountId from the row). */
|
||||
async function mintPairingCode(stores: Stores, accountId: string): Promise<string> {
|
||||
const issuer = createPairingIssuer({ pairing: stores.pairing, pairingTtlSec: 3600 })
|
||||
const { code } = await issuer.issuePairingCode(accountId)
|
||||
return code
|
||||
}
|
||||
|
||||
/** A fresh P-256 host identity: its real PKCS#10 CSR + the SPKI DER the agent presents as agentPubkey. */
|
||||
async function ecHostIdentity(subject = 'CN=web-terminal-host'): Promise<{ csr: Uint8Array; spki: Uint8Array }> {
|
||||
const { der: csr, keys } = await buildCsrEc(subject)
|
||||
const spki = new Uint8Array(await webcrypto.subtle.exportKey('spki', keys.publicKey))
|
||||
return { csr, spki }
|
||||
}
|
||||
|
||||
/** Bind a host whose frp-client identity is a P-256 key (so the wired host signer can issue for it). */
|
||||
async function bindP256Host(
|
||||
stores: Stores,
|
||||
accountId: string,
|
||||
subdomain: string,
|
||||
): Promise<{ hostId: string; csr: Uint8Array; spki: Uint8Array }> {
|
||||
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||
const { der: csr, keys } = await buildCsrEc(`CN=${subdomain}`)
|
||||
const spki = new Uint8Array(await webcrypto.subtle.exportKey('spki', keys.publicKey))
|
||||
const host = await hosts.bindHost({ accountId, subdomain, agentPubkey: spki, enrollFpr: fingerprint(spki) })
|
||||
return { hostId: host.hostId, csr, spki }
|
||||
}
|
||||
|
||||
function b64(bytes: Uint8Array): string {
|
||||
return Buffer.from(bytes).toString('base64')
|
||||
}
|
||||
|
||||
function sanNames(der: Uint8Array): ReturnType<x509.GeneralNames['toJSON']> {
|
||||
const leaf = new x509.X509Certificate(der)
|
||||
return leaf.getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON()
|
||||
}
|
||||
|
||||
describe('native-tunnel wiring — DEVICE e2e (enroll → renew)', () => {
|
||||
test('enroll an owned subdomain → 201 real leaf chaining to the dev device-CA → renew → 201 fresh cert, same subdomain', async () => {
|
||||
const { app, stores, built } = await buildApp()
|
||||
await ownSubdomain(stores, ACCOUNT_A, 'alice')
|
||||
const token = await mintDeviceEnrollToken(ACCOUNT_A, { signingKey })
|
||||
const { der: csr } = await buildCsrEc('CN=web-terminal-device')
|
||||
|
||||
// ── ENROLL ───────────────────────────────────────────────────────────────────────────────────
|
||||
const enroll = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { csr: b64(csr), keyAlg: 'ec-p256', subdomain: 'alice', deviceName: 'iphone' },
|
||||
})
|
||||
expect(enroll.statusCode).toBe(201)
|
||||
const enrolled = JSON.parse(enroll.body)
|
||||
expect(typeof enrolled.deviceId).toBe('string')
|
||||
expect(enrolled.deviceId.length).toBeGreaterThan(0)
|
||||
|
||||
// the returned cert re-parses as a real X.509 leaf …
|
||||
const leafDer = new Uint8Array(Buffer.from(enrolled.cert, 'base64'))
|
||||
const leaf = new x509.X509Certificate(leafDer)
|
||||
// … chains to the CA the app returned …
|
||||
const caDer = new Uint8Array(Buffer.from(enrolled.caChain[0], 'base64'))
|
||||
const caCert = new x509.X509Certificate(caDer)
|
||||
expect(await leaf.verify({ publicKey: caCert.publicKey, signatureOnly: true })).toBe(true)
|
||||
// … which IS the wired dev device-CA anchor …
|
||||
expect(b64(caDer)).toBe(b64(built.nativeTunnel.nativeCas.deviceCa.caCertDer))
|
||||
// … and carries the tenant-isolation dNSName SAN.
|
||||
expect(sanNames(leafDer)).toContainEqual({ type: 'dns', value: `alice.${DNS_ZONE}` })
|
||||
|
||||
// ── RENEW (present the enroll-issued cert as the current mTLS client cert) ──────────────────────
|
||||
const renew = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/device/${enrolled.deviceId}/renew`,
|
||||
headers: { 'x-client-cert': b64(leafDer) },
|
||||
payload: { csr: b64(csr) }, // same-key CSR
|
||||
})
|
||||
expect(renew.statusCode).toBe(201)
|
||||
const renewed = JSON.parse(renew.body)
|
||||
const renewedDer = new Uint8Array(Buffer.from(renewed.cert, 'base64'))
|
||||
expect(() => new x509.X509Certificate(renewedDer)).not.toThrow()
|
||||
// same subdomain — the renew stamps the registry scope, never client input.
|
||||
expect(sanNames(renewedDer)).toContainEqual({ type: 'dns', value: `alice.${DNS_ZONE}` })
|
||||
// and the fresh cert still chains to the device-CA anchor.
|
||||
expect(await new x509.X509Certificate(renewedDer).verify({ publicKey: caCert.publicKey, signatureOnly: true })).toBe(true)
|
||||
})
|
||||
|
||||
test('enroll for an UNOWNED subdomain → 403, no cert issued (A4 ownership gate, end-to-end)', async () => {
|
||||
const { app, stores } = await buildApp()
|
||||
await ownSubdomain(stores, ACCOUNT_A, 'alice')
|
||||
const token = await mintDeviceEnrollToken(ACCOUNT_A, { signingKey })
|
||||
const { der: csr } = await buildCsrEc('CN=web-terminal-device')
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { csr: b64(csr), keyAlg: 'ec-p256', subdomain: 'charlie', deviceName: 'x' },
|
||||
})
|
||||
expect(res.statusCode).toBe(403)
|
||||
expect(JSON.parse(res.body).cert).toBeUndefined()
|
||||
})
|
||||
|
||||
test('account B enrolling account A\'s subdomain → 403 (cross-tenant, no cert)', async () => {
|
||||
const { app, stores } = await buildApp()
|
||||
await ownSubdomain(stores, ACCOUNT_A, 'alice')
|
||||
const tokenB = await mintDeviceEnrollToken(ACCOUNT_B, { signingKey })
|
||||
const { der: csr } = await buildCsrEc('CN=web-terminal-device')
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
headers: { authorization: `Bearer ${tokenB}` },
|
||||
payload: { csr: b64(csr), keyAlg: 'ec-p256', subdomain: 'alice', deviceName: 'x' },
|
||||
})
|
||||
expect(res.statusCode).toBe(403)
|
||||
expect(JSON.parse(res.body).cert).toBeUndefined()
|
||||
})
|
||||
|
||||
test('missing enroll token → 401 (deny-by-default through the wired verifier seam)', async () => {
|
||||
const { app, stores } = await buildApp()
|
||||
await ownSubdomain(stores, ACCOUNT_A, 'alice')
|
||||
const { der: csr } = await buildCsrEc('CN=web-terminal-device')
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
payload: { csr: b64(csr), keyAlg: 'ec-p256', subdomain: 'alice', deviceName: 'x' },
|
||||
})
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('native-tunnel wiring — HOST /renew e2e', () => {
|
||||
test('initial frp-client leaf (wired host signer) → /renew → 201 fresh cert, SAME subdomain, chains to frp-client-CA', async () => {
|
||||
const { app, stores, built } = await buildApp()
|
||||
const { hostId, csr, spki } = await bindP256Host(stores, ACCOUNT_A, 'alice')
|
||||
// Initial leaf, minted by the WIRED host signer (chains to the wired frp-client-CA anchor).
|
||||
const { cert: currentCert } = await built.nativeTunnel.hostSigner.signHostLeaf(hostId, spki, csr)
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/renew',
|
||||
headers: { 'x-client-cert': b64(currentCert) },
|
||||
payload: { csr: b64(csr) },
|
||||
})
|
||||
expect(res.statusCode).toBe(201)
|
||||
const renewedDer = new Uint8Array(Buffer.from(JSON.parse(res.body).cert, 'base64'))
|
||||
expect(sanNames(renewedDer)).toContainEqual({ type: 'dns', value: `alice.${DNS_ZONE}` })
|
||||
const caCert = new x509.X509Certificate(built.nativeTunnel.nativeCas.frpClientCa.caCertDer)
|
||||
expect(await new x509.X509Certificate(renewedDer).verify({ publicKey: caCert.publicKey, signatureOnly: true })).toBe(true)
|
||||
})
|
||||
|
||||
test('a current cert from an UNTRUSTED (different-instance) frp-client-CA is rejected by the wired anchor set → 401', async () => {
|
||||
const { app } = await buildApp()
|
||||
// A second, independent wired control-plane = a DIFFERENT (untrusted) frp-client-CA. Bind the same
|
||||
// host+identity there and mint a leaf under ITS CA, then present it to the REAL app's /renew.
|
||||
const rogueStores = createMemoryStores()
|
||||
const rogue = await buildControlPlane(env, { stores: rogueStores, verifier: createCapabilityVerifier() })
|
||||
const rogueBound = await bindP256Host(rogueStores, ACCOUNT_A, 'alice')
|
||||
const { cert: rogueCert } = await rogue.nativeTunnel.hostSigner.signHostLeaf(
|
||||
rogueBound.hostId,
|
||||
rogueBound.spki,
|
||||
rogueBound.csr,
|
||||
)
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/renew',
|
||||
headers: { 'x-client-cert': b64(rogueCert) },
|
||||
payload: { csr: b64(rogueBound.csr) },
|
||||
})
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('native-tunnel wiring — HOST /enroll HTTP arm (P-256 frp-client leaf)', () => {
|
||||
test('full host onboard: mint code → POST /enroll (EC CSR) → 201 frp-client leaf (chains to frp-client-CA + dNSName SAN, hostContentSecret null) → /renew → 201 SAME subdomain', async () => {
|
||||
const { app, stores, built } = await buildApp()
|
||||
const code = await mintPairingCode(stores, ACCOUNT_A)
|
||||
const { csr, spki } = await ecHostIdentity()
|
||||
|
||||
// ── ENROLL (native P-256 arm — routed by CSR key algorithm) ────────────────────────────────────
|
||||
const enroll = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/enroll',
|
||||
payload: { code, machineId: 'MB-A1B2C3', agentPubkey: b64(spki), csr: b64(csr) },
|
||||
})
|
||||
expect(enroll.statusCode).toBe(201)
|
||||
const body = JSON.parse(enroll.body)
|
||||
expect(typeof body.hostId).toBe('string')
|
||||
expect(body.hostId.length).toBeGreaterThan(0)
|
||||
expect(typeof body.subdomain).toBe('string')
|
||||
expect(body.subdomain.length).toBeGreaterThan(0)
|
||||
// native tunnel has no E2E-relay content secret (L-host-hcs).
|
||||
expect(body.hostContentSecret).toBeNull()
|
||||
|
||||
// the returned cert re-parses as a real X.509 leaf …
|
||||
const leafDer = new Uint8Array(Buffer.from(body.cert, 'base64'))
|
||||
const leaf = new x509.X509Certificate(leafDer)
|
||||
// … chains to the wired frp-client-CA anchor …
|
||||
const caCert = new x509.X509Certificate(built.nativeTunnel.nativeCas.frpClientCa.caCertDer)
|
||||
expect(await leaf.verify({ publicKey: caCert.publicKey, signatureOnly: true })).toBe(true)
|
||||
const caDer = new Uint8Array(Buffer.from(body.caChain[0], 'base64'))
|
||||
expect(b64(caDer)).toBe(b64(built.nativeTunnel.nativeCas.frpClientCa.caCertDer))
|
||||
// … and carries the AUTHORITATIVE (server-assigned) dNSName SAN <sub>.<zone>.
|
||||
expect(sanNames(leafDer)).toContainEqual({ type: 'dns', value: `${body.subdomain}.${DNS_ZONE}` })
|
||||
|
||||
// the host binding persisted the SAME authoritative subdomain (not client input).
|
||||
const host = await stores.hosts.get(body.hostId)
|
||||
expect(host?.subdomain).toBe(body.subdomain)
|
||||
expect(host?.accountId).toBe(ACCOUNT_A)
|
||||
|
||||
// ── RENEW (present the enroll-issued cert as the current mTLS client cert) ──────────────────────
|
||||
const renew = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/renew',
|
||||
headers: { 'x-client-cert': b64(leafDer) },
|
||||
payload: { csr: b64(csr) }, // same-key CSR
|
||||
})
|
||||
expect(renew.statusCode).toBe(201)
|
||||
const renewedDer = new Uint8Array(Buffer.from(JSON.parse(renew.body).cert, 'base64'))
|
||||
// the renew stamps the registry scope — SAME subdomain, never client input.
|
||||
expect(sanNames(renewedDer)).toContainEqual({ type: 'dns', value: `${body.subdomain}.${DNS_ZONE}` })
|
||||
expect(await new x509.X509Certificate(renewedDer).verify({ publicKey: caCert.publicKey, signatureOnly: true })).toBe(true)
|
||||
})
|
||||
|
||||
test('anti-smuggling: a client body requesting a DIFFERENT subdomain does NOT change the issued SAN', async () => {
|
||||
const { app, stores } = await buildApp()
|
||||
const code = await mintPairingCode(stores, ACCOUNT_A)
|
||||
const { csr, spki } = await ecHostIdentity()
|
||||
|
||||
const enroll = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/enroll',
|
||||
// the attacker tries to steer the SAN to a subdomain it was never assigned:
|
||||
payload: { code, machineId: 'x', agentPubkey: b64(spki), csr: b64(csr), subdomain: 'victim' },
|
||||
})
|
||||
expect(enroll.statusCode).toBe(201)
|
||||
const body = JSON.parse(enroll.body)
|
||||
// the SAN is the SERVER-assigned subdomain, never the client-supplied 'victim'.
|
||||
expect(body.subdomain).not.toBe('victim')
|
||||
const leafDer = new Uint8Array(Buffer.from(body.cert, 'base64'))
|
||||
expect(sanNames(leafDer)).toContainEqual({ type: 'dns', value: `${body.subdomain}.${DNS_ZONE}` })
|
||||
expect(sanNames(leafDer)).not.toContainEqual({ type: 'dns', value: `victim.${DNS_ZONE}` })
|
||||
// and the host registry bound the server-assigned subdomain, not 'victim'.
|
||||
const host = await stores.hosts.get(body.hostId)
|
||||
expect(host?.subdomain).toBe(body.subdomain)
|
||||
expect(host?.subdomain).not.toBe('victim')
|
||||
})
|
||||
|
||||
test('single-use: replaying an already-redeemed pairing code → NOT 201 (double-spend guard)', async () => {
|
||||
const { app, stores } = await buildApp()
|
||||
const code = await mintPairingCode(stores, ACCOUNT_A)
|
||||
const first = await ecHostIdentity('CN=host-1')
|
||||
const ok = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/enroll',
|
||||
payload: { code, machineId: 'a', agentPubkey: b64(first.spki), csr: b64(first.csr) },
|
||||
})
|
||||
expect(ok.statusCode).toBe(201)
|
||||
|
||||
const second = await ecHostIdentity('CN=host-2')
|
||||
const replay = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/enroll',
|
||||
payload: { code, machineId: 'a', agentPubkey: b64(second.spki), csr: b64(second.csr) },
|
||||
})
|
||||
expect(replay.statusCode).not.toBe(201)
|
||||
})
|
||||
|
||||
test('no-substitution: agentPubkey that does not match the CSR key → NOT 201', async () => {
|
||||
const { app, stores } = await buildApp()
|
||||
const code = await mintPairingCode(stores, ACCOUNT_A)
|
||||
const { csr } = await ecHostIdentity('CN=host-real')
|
||||
const other = await ecHostIdentity('CN=host-other')
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/enroll',
|
||||
// present a DIFFERENT key than the CSR embeds — the gate's no-substitution check must reject.
|
||||
payload: { code, machineId: 'a', agentPubkey: b64(other.spki), csr: b64(csr) },
|
||||
})
|
||||
expect(res.statusCode).not.toBe(201)
|
||||
})
|
||||
|
||||
test('non-regression: an Ed25519 relay /enroll still issues the PEM relay leaf + wrapped secret (native arm does not hijack it)', async () => {
|
||||
const { app, stores } = await buildApp()
|
||||
const code = await mintPairingCode(stores, ACCOUNT_A)
|
||||
const { publicKeyRaw, privateKey } = generateEd25519()
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/enroll',
|
||||
payload: { code, agentPubkey: bytesToBase64(publicKeyRaw), csr: bytesToBase64(buildCsr(privateKey, publicKeyRaw)) },
|
||||
})
|
||||
expect(res.statusCode).toBe(201)
|
||||
const body = JSON.parse(res.body)
|
||||
expect(body.cert).toContain('BEGIN CERTIFICATE') // PEM relay leaf, unchanged
|
||||
expect(typeof body.hostContentSecret).toBe('string') // base64url wrapped secret, NOT null
|
||||
})
|
||||
})
|
||||
95
control-plane/test/registry/devices.test.ts
Normal file
95
control-plane/test/registry/devices.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* A4 — device registry (mirrors registry/hosts.ts). Proves: create/get; versioned status swap
|
||||
* (INV8 append-only companion rows + atomic pointer swap); per-account device CAP enforcement;
|
||||
* per-account enroll RATE-LIMIT; ownership deny-by-default.
|
||||
*/
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import {
|
||||
createDeviceRegistry,
|
||||
createMemoryDeviceStore,
|
||||
DeviceLimitError,
|
||||
type RegisterDeviceInput,
|
||||
} from '../../src/registry/devices.js'
|
||||
|
||||
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
|
||||
const ACCOUNT_B = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function input(overrides: Partial<RegisterDeviceInput> = {}): RegisterDeviceInput {
|
||||
return {
|
||||
accountId: ACCOUNT_A,
|
||||
subdomainScope: 'alice',
|
||||
ecPubkeySpki: new Uint8Array([1, 2, 3, 4]),
|
||||
attestationLevel: 'none',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('A4 device registry', () => {
|
||||
test('registerDevice → getDevice returns the persisted record with a serial + notAfter', async () => {
|
||||
const devices = createDeviceRegistry({ devices: createMemoryDeviceStore() })
|
||||
const rec = await devices.registerDevice(input())
|
||||
expect(rec.deviceId.length).toBeGreaterThan(0)
|
||||
expect(rec.accountId).toBe(ACCOUNT_A)
|
||||
expect(rec.subdomainScope).toBe('alice')
|
||||
expect(rec.status).toBe('active')
|
||||
expect(rec.serial.length).toBeGreaterThan(0)
|
||||
expect(Date.parse(rec.notAfter)).toBeGreaterThan(Date.now())
|
||||
expect(await devices.getDevice(rec.deviceId)).toEqual(rec)
|
||||
})
|
||||
|
||||
test('ecPubkeySpki is defensively copied (public key only, immutable)', async () => {
|
||||
const devices = createDeviceRegistry({ devices: createMemoryDeviceStore() })
|
||||
const src = new Uint8Array([9, 9, 9])
|
||||
const rec = await devices.registerDevice(input({ ecPubkeySpki: src }))
|
||||
src[0] = 0 // mutate the caller's buffer
|
||||
expect(Array.from(rec.ecPubkeySpki)).toEqual([9, 9, 9])
|
||||
})
|
||||
|
||||
test('setDeviceStatus revokes with a versioned snapshot (INV8)', async () => {
|
||||
const store = createMemoryDeviceStore()
|
||||
const devices = createDeviceRegistry({ devices: store })
|
||||
const rec = await devices.registerDevice(input())
|
||||
const revoked = await devices.setDeviceStatus(rec.deviceId, 'revoked')
|
||||
expect(revoked.status).toBe('revoked')
|
||||
expect(revoked.revokedAt).not.toBeNull()
|
||||
const versions = await store.versions(rec.deviceId)
|
||||
expect(versions.length).toBe(2) // insert + revoke
|
||||
expect(versions[1]?.status).toBe('revoked')
|
||||
})
|
||||
|
||||
test('ownsDevice is deny-by-default (unknown device / foreign account ⇒ false)', async () => {
|
||||
const devices = createDeviceRegistry({ devices: createMemoryDeviceStore() })
|
||||
const rec = await devices.registerDevice(input())
|
||||
expect(await devices.ownsDevice(ACCOUNT_A, rec.deviceId)).toBe(true)
|
||||
expect(await devices.ownsDevice(ACCOUNT_B, rec.deviceId)).toBe(false)
|
||||
expect(await devices.ownsDevice(ACCOUNT_A, 'nope')).toBe(false)
|
||||
})
|
||||
|
||||
test('per-account device CAP is enforced (active count only)', async () => {
|
||||
const devices = createDeviceRegistry({ devices: createMemoryDeviceStore(), deviceCap: 2 })
|
||||
await devices.assertUnderCap(ACCOUNT_A) // 0 < 2
|
||||
const r1 = await devices.registerDevice(input())
|
||||
await devices.registerDevice(input())
|
||||
await expect(devices.assertUnderCap(ACCOUNT_A)).rejects.toBeInstanceOf(DeviceLimitError)
|
||||
// a different account is unaffected
|
||||
await devices.assertUnderCap(ACCOUNT_B)
|
||||
// revoking one frees a slot
|
||||
await devices.setDeviceStatus(r1.deviceId, 'revoked')
|
||||
await devices.assertUnderCap(ACCOUNT_A)
|
||||
})
|
||||
|
||||
test('per-account enroll RATE-LIMIT throws after the window budget', () => {
|
||||
const devices = createDeviceRegistry({ devices: createMemoryDeviceStore(), rateMax: 2 })
|
||||
devices.checkRateLimit(ACCOUNT_A)
|
||||
devices.checkRateLimit(ACCOUNT_A)
|
||||
expect(() => devices.checkRateLimit(ACCOUNT_A)).toThrow(DeviceLimitError)
|
||||
// a different account has its own budget
|
||||
expect(() => devices.checkRateLimit(ACCOUNT_B)).not.toThrow()
|
||||
})
|
||||
|
||||
test('DeviceLimitError carries a machine code (cap vs rate) but a uniform message', async () => {
|
||||
const devices = createDeviceRegistry({ devices: createMemoryDeviceStore(), deviceCap: 0 })
|
||||
await devices.registerDevice(input()).catch(() => undefined)
|
||||
await expect(devices.assertUnderCap(ACCOUNT_A)).rejects.toMatchObject({ code: 'cap_exceeded' })
|
||||
})
|
||||
})
|
||||
523
control-plane/test/renew.test.ts
Normal file
523
control-plane/test/renew.test.ts
Normal file
@@ -0,0 +1,523 @@
|
||||
/**
|
||||
* A6 (FIX H-host-3) — POST /renew (host) + POST /device/:id/renew (device) route tests (fastify
|
||||
* inject). Both routes are authenticated ONLY by the CURRENT client cert the mTLS terminator forwards
|
||||
* (injected here via the `x-client-cert` header); the body carries ONLY the new CSR. Proves: a valid
|
||||
* current cert + a valid SAME-KEY CSR → 201 with a real X.509 leaf carrying the SAME subdomain (no
|
||||
* smuggling); a missing / malformed current cert → 401; a revoked / unknown / mismatched identity →
|
||||
* 403; a DIFFERENT-key CSR → 400 (MVP same-key); over the per-identity rate → 429.
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import Fastify, { type FastifyInstance } from 'fastify'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { webcrypto, generateKeyPairSync, randomUUID } from 'node:crypto'
|
||||
import { createMemoryStores } from '../src/store/memory.js'
|
||||
import { createHostRegistry, type HostRegistry } from '../src/registry/hosts.js'
|
||||
import { createDeviceRegistry, createMemoryDeviceStore, type DeviceRegistry } from '../src/registry/devices.js'
|
||||
import { fingerprint } from '../src/ca/fingerprint.js'
|
||||
import { assembleCertificate } from '../src/ca/x509-assembler.js'
|
||||
import { buildCsrEc } from '../src/ca/csr-ec.js'
|
||||
import { createFrpClientLeafSigner } from '../src/ca/frpclient-issue.js'
|
||||
import { createDeviceLeafSigner, type DeviceLeafSigner } from '../src/ca/device-issue.js'
|
||||
import { createLeafRenewer } from '../src/ca/rotate.js'
|
||||
import type { LeafSigner } from '../src/ca/sign.js'
|
||||
import { inProcessP256CaSigner, type CaSigner } from '../src/boot/ca-wiring.js'
|
||||
import { buildRenewRouter, createRenewRateLimiter, type RenewDeps } from '../src/api/renew.js'
|
||||
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const TRUST_DOMAIN = 'terminal.yaojia.wang'
|
||||
const ZONE = 'terminal.yaojia.wang'
|
||||
|
||||
interface P256Ca {
|
||||
readonly caSigner: CaSigner
|
||||
readonly caCert: x509.X509Certificate
|
||||
readonly caDer: Uint8Array
|
||||
}
|
||||
|
||||
async function makeP256Ca(cn: string): Promise<P256Ca> {
|
||||
const caKeys = generateKeyPairSync('ec', { namedCurve: 'P-256' })
|
||||
const caSpki = new Uint8Array(caKeys.publicKey.export({ format: 'der', type: 'spki' }))
|
||||
const caSigner = inProcessP256CaSigner(caKeys.privateKey)
|
||||
const now = Date.now()
|
||||
const caDer = await assembleCertificate({
|
||||
subjectPublicKey: caSpki,
|
||||
subject: `CN=${cn}`,
|
||||
issuer: `CN=${cn}`,
|
||||
serialNumber: Uint8Array.from([0x01]),
|
||||
notBefore: new Date(now - DAY_MS),
|
||||
notAfter: new Date(now + 365 * DAY_MS),
|
||||
extensions: [
|
||||
new x509.BasicConstraintsExtension(true, undefined, true),
|
||||
new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign, true),
|
||||
],
|
||||
signer: caSigner,
|
||||
sigAlg: 'ecdsa-p256',
|
||||
})
|
||||
return { caSigner, caCert: new x509.X509Certificate(caDer), caDer }
|
||||
}
|
||||
|
||||
async function spkiOf(pub: CryptoKey): Promise<Uint8Array> {
|
||||
return new Uint8Array(await webcrypto.subtle.exportKey('spki', pub))
|
||||
}
|
||||
|
||||
/** base64-DER cert value for the `x-client-cert` header (what the mTLS terminator forwards). */
|
||||
function certHeader(der: Uint8Array): string {
|
||||
return Buffer.from(der).toString('base64')
|
||||
}
|
||||
|
||||
function b64Csr(der: Uint8Array): string {
|
||||
return Buffer.from(der).toString('base64')
|
||||
}
|
||||
|
||||
function appWith(deps: RenewDeps): FastifyInstance {
|
||||
const app = Fastify({ logger: false })
|
||||
void app.register(buildRenewRouter(deps))
|
||||
return app
|
||||
}
|
||||
|
||||
// ── HOST /renew ──────────────────────────────────────────────────────────────────────────────────
|
||||
interface HostCtx {
|
||||
hosts: HostRegistry
|
||||
renewer: ReturnType<typeof createLeafRenewer>
|
||||
hostSigner: LeafSigner
|
||||
ca: P256Ca
|
||||
subdomain: string
|
||||
spki: Uint8Array
|
||||
keys: { readonly publicKey: CryptoKey; readonly privateKey: CryptoKey }
|
||||
csr: Uint8Array
|
||||
currentCertDer: Uint8Array
|
||||
accountId: string
|
||||
deviceSigner: DeviceLeafSigner
|
||||
}
|
||||
|
||||
async function hostCtx(subdomain = 'alice', hosts?: HostRegistry): Promise<HostCtx> {
|
||||
const ca = await makeP256Ca('frp-client-CA')
|
||||
const stores = createMemoryStores()
|
||||
const reg = hosts ?? createHostRegistry({ hosts: stores.hosts })
|
||||
const { der: csr, keys } = await buildCsrEc(`CN=${subdomain}`)
|
||||
const spki = await spkiOf(keys.publicKey)
|
||||
const accountId = randomUUID()
|
||||
const host = await reg.bindHost({ accountId, subdomain, agentPubkey: spki, enrollFpr: fingerprint(spki) })
|
||||
const hostSigner = createFrpClientLeafSigner({
|
||||
hosts: stores.hosts,
|
||||
signer: ca.caSigner,
|
||||
issuerName: ca.caCert.subjectName,
|
||||
caChainDer: [ca.caDer],
|
||||
trustDomain: TRUST_DOMAIN,
|
||||
dnsZone: ZONE,
|
||||
})
|
||||
const throwawayDeviceStore = createMemoryDeviceStore()
|
||||
const deviceSigner = createDeviceLeafSigner({
|
||||
signer: (await makeP256Ca('device-CA')).caSigner,
|
||||
issuer: 'CN=device-CA',
|
||||
caChainDer: [],
|
||||
sanBaseDomain: ZONE,
|
||||
trustDomain: TRUST_DOMAIN,
|
||||
devices: throwawayDeviceStore,
|
||||
})
|
||||
const renewer = createLeafRenewer({
|
||||
hostSigner,
|
||||
deviceSigner,
|
||||
deviceExpiry: createDeviceRegistry({ devices: throwawayDeviceStore }),
|
||||
})
|
||||
// The CURRENT cert = an initial leaf issued for this host (what the mTLS layer would present).
|
||||
const { cert: currentCertDer } = await hostSigner.signHostLeaf(host.hostId, spki, csr)
|
||||
return { hosts: reg, renewer, hostSigner, ca, subdomain, spki, keys, csr, currentCertDer, accountId, deviceSigner }
|
||||
}
|
||||
|
||||
function hostDeps(ctx: HostCtx, extra?: Partial<RenewDeps>): RenewDeps {
|
||||
return {
|
||||
hosts: ctx.hosts,
|
||||
devices: createDeviceRegistry({ devices: createMemoryDeviceStore() }),
|
||||
renewer: ctx.renewer,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
describe('A6 POST /renew (host, mTLS current cert)', () => {
|
||||
test('valid current cert + same-key CSR → 201 real X.509 leaf carrying the SAME subdomain', async () => {
|
||||
const ctx = await hostCtx('alice')
|
||||
const app = appWith(hostDeps(ctx))
|
||||
await app.ready()
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/renew',
|
||||
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
|
||||
payload: { csr: b64Csr(ctx.csr) },
|
||||
})
|
||||
expect(res.statusCode).toBe(201)
|
||||
const body = JSON.parse(res.body)
|
||||
expect(typeof body.notAfter).toBe('string')
|
||||
expect(Array.isArray(body.caChain)).toBe(true)
|
||||
const leaf = new x509.X509Certificate(new Uint8Array(Buffer.from(body.cert, 'base64')))
|
||||
const names = leaf.getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON()
|
||||
expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' })
|
||||
})
|
||||
|
||||
test('missing current cert → 401', async () => {
|
||||
const ctx = await hostCtx('alice')
|
||||
const app = appWith(hostDeps(ctx))
|
||||
await app.ready()
|
||||
const res = await app.inject({ method: 'POST', url: '/renew', payload: { csr: b64Csr(ctx.csr) } })
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
test('malformed current cert bytes → 401', async () => {
|
||||
const ctx = await hostCtx('alice')
|
||||
const app = appWith(hostDeps(ctx))
|
||||
await app.ready()
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/renew',
|
||||
headers: { 'x-client-cert': certHeader(Uint8Array.from([0, 1, 2, 3])) },
|
||||
payload: { csr: b64Csr(ctx.csr) },
|
||||
})
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
test('CSR with a DIFFERENT key (key-swap attempt) → 400 (MVP same-key)', async () => {
|
||||
const ctx = await hostCtx('alice')
|
||||
const app = appWith(hostDeps(ctx))
|
||||
await app.ready()
|
||||
const { der: otherCsr } = await buildCsrEc('CN=alice')
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/renew',
|
||||
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
|
||||
payload: { csr: b64Csr(otherCsr) },
|
||||
})
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
test('revoked host → 403', async () => {
|
||||
const ctx = await hostCtx('alice')
|
||||
const host = await ctx.hosts.getHostBySubdomain('alice')
|
||||
await ctx.hosts.setHostStatus(host!.hostId, 'revoked')
|
||||
const app = appWith(hostDeps(ctx))
|
||||
await app.ready()
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/renew',
|
||||
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
|
||||
payload: { csr: b64Csr(ctx.csr) },
|
||||
})
|
||||
expect(res.statusCode).toBe(403)
|
||||
})
|
||||
|
||||
test('current cert whose subdomain is unknown to the registry → 403', async () => {
|
||||
const ctx = await hostCtx('alice')
|
||||
// Route uses a FRESH empty registry — the presented cert's subdomain resolves to nothing.
|
||||
const emptyStores = createMemoryStores()
|
||||
const app = appWith({
|
||||
hosts: createHostRegistry({ hosts: emptyStores.hosts }),
|
||||
devices: createDeviceRegistry({ devices: createMemoryDeviceStore() }),
|
||||
renewer: ctx.renewer,
|
||||
})
|
||||
await app.ready()
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/renew',
|
||||
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
|
||||
payload: { csr: b64Csr(ctx.csr) },
|
||||
})
|
||||
expect(res.statusCode).toBe(403)
|
||||
})
|
||||
|
||||
test('a renewal CSR that requests a foreign subject is stamped with the REGISTRY subdomain (no smuggling)', async () => {
|
||||
const ctx = await hostCtx('alice')
|
||||
const app = appWith(hostDeps(ctx))
|
||||
await app.ready()
|
||||
// Same key, but a malicious subject label — the issuer must ignore it and stamp 'alice'.
|
||||
const evilCsr = new Uint8Array(
|
||||
(
|
||||
await x509.Pkcs10CertificateRequestGenerator.create({
|
||||
name: 'CN=bob.terminal.yaojia.wang',
|
||||
keys: ctx.keys,
|
||||
signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' },
|
||||
})
|
||||
).rawData,
|
||||
)
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/renew',
|
||||
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
|
||||
payload: { csr: b64Csr(evilCsr) },
|
||||
})
|
||||
expect(res.statusCode).toBe(201)
|
||||
const leaf = new x509.X509Certificate(new Uint8Array(Buffer.from(JSON.parse(res.body).cert, 'base64')))
|
||||
const names = leaf.getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON()
|
||||
expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' })
|
||||
expect(names).not.toContainEqual({ type: 'dns', value: 'bob.terminal.yaojia.wang' })
|
||||
})
|
||||
|
||||
test('over the per-identity renewal rate → 429', async () => {
|
||||
const ctx = await hostCtx('alice')
|
||||
const app = appWith(hostDeps(ctx, { rateLimiter: createRenewRateLimiter({ max: 1 }) }))
|
||||
await app.ready()
|
||||
const first = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/renew',
|
||||
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
|
||||
payload: { csr: b64Csr(ctx.csr) },
|
||||
})
|
||||
expect(first.statusCode).toBe(201)
|
||||
const second = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/renew',
|
||||
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
|
||||
payload: { csr: b64Csr(ctx.csr) },
|
||||
})
|
||||
expect(second.statusCode).toBe(429)
|
||||
})
|
||||
})
|
||||
|
||||
// ── DEVICE /device/:id/renew ─────────────────────────────────────────────────────────────────────
|
||||
interface DeviceCtx {
|
||||
devices: DeviceRegistry
|
||||
renewer: ReturnType<typeof createLeafRenewer>
|
||||
deviceSigner: DeviceLeafSigner
|
||||
ca: P256Ca
|
||||
deviceId: string
|
||||
spki: Uint8Array
|
||||
csr: Uint8Array
|
||||
currentCertDer: Uint8Array
|
||||
}
|
||||
|
||||
async function registerDeviceLeaf(
|
||||
registry: DeviceRegistry,
|
||||
store: ReturnType<typeof createMemoryDeviceStore>,
|
||||
deviceSigner: DeviceLeafSigner,
|
||||
scope = 'alice',
|
||||
) {
|
||||
const { der: csr, keys } = await buildCsrEc('CN=web-terminal-device')
|
||||
const spki = await spkiOf(keys.publicKey)
|
||||
const record = await registry.registerDevice({ accountId: randomUUID(), subdomainScope: scope, ecPubkeySpki: spki, attestationLevel: 'none' })
|
||||
const { cert: currentCertDer } = await deviceSigner.signDeviceLeaf(record.deviceId, csr)
|
||||
return { record, csr, spki, currentCertDer }
|
||||
}
|
||||
|
||||
async function deviceCtx(scope = 'alice'): Promise<DeviceCtx> {
|
||||
const ca = await makeP256Ca('device-CA')
|
||||
const store = createMemoryDeviceStore()
|
||||
const registry = createDeviceRegistry({ devices: store })
|
||||
const deviceSigner = createDeviceLeafSigner({
|
||||
signer: ca.caSigner,
|
||||
issuer: ca.caCert.subjectName,
|
||||
caChainDer: [ca.caDer],
|
||||
sanBaseDomain: ZONE,
|
||||
trustDomain: TRUST_DOMAIN,
|
||||
devices: store,
|
||||
})
|
||||
const hostSigner = createFrpClientLeafSigner({
|
||||
hosts: createMemoryStores().hosts,
|
||||
signer: ca.caSigner,
|
||||
issuerName: 'CN=frp-client-CA',
|
||||
caChainDer: [],
|
||||
trustDomain: TRUST_DOMAIN,
|
||||
dnsZone: ZONE,
|
||||
})
|
||||
const renewer = createLeafRenewer({ hostSigner, deviceSigner, deviceExpiry: registry })
|
||||
const { record, csr, spki, currentCertDer } = await registerDeviceLeaf(registry, store, deviceSigner, scope)
|
||||
return { devices: registry, renewer, deviceSigner, ca, deviceId: record.deviceId, spki, csr, currentCertDer }
|
||||
}
|
||||
|
||||
function deviceDeps(ctx: DeviceCtx): RenewDeps {
|
||||
return {
|
||||
hosts: createHostRegistry({ hosts: createMemoryStores().hosts }),
|
||||
devices: ctx.devices,
|
||||
renewer: ctx.renewer,
|
||||
}
|
||||
}
|
||||
|
||||
describe('A6 POST /device/:id/renew (device, mTLS current cert)', () => {
|
||||
test('valid current cert + same-key CSR → 201 real X.509 leaf with the SAME subdomainScope', async () => {
|
||||
const ctx = await deviceCtx('alice')
|
||||
const app = appWith(deviceDeps(ctx))
|
||||
await app.ready()
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/device/${ctx.deviceId}/renew`,
|
||||
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
|
||||
payload: { csr: b64Csr(ctx.csr) },
|
||||
})
|
||||
expect(res.statusCode).toBe(201)
|
||||
const leaf = new x509.X509Certificate(new Uint8Array(Buffer.from(JSON.parse(res.body).cert, 'base64')))
|
||||
const names = leaf.getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON()
|
||||
expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' })
|
||||
})
|
||||
|
||||
test('missing current cert → 401', async () => {
|
||||
const ctx = await deviceCtx('alice')
|
||||
const app = appWith(deviceDeps(ctx))
|
||||
await app.ready()
|
||||
const res = await app.inject({ method: 'POST', url: `/device/${ctx.deviceId}/renew`, payload: { csr: b64Csr(ctx.csr) } })
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
test('CSR with a DIFFERENT key → 400 (MVP same-key)', async () => {
|
||||
const ctx = await deviceCtx('alice')
|
||||
const app = appWith(deviceDeps(ctx))
|
||||
await app.ready()
|
||||
const { der: otherCsr } = await buildCsrEc('CN=web-terminal-device')
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/device/${ctx.deviceId}/renew`,
|
||||
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
|
||||
payload: { csr: b64Csr(otherCsr) },
|
||||
})
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
test("presenting device X's cert against a DIFFERENT device's :id → 403", async () => {
|
||||
const ctx = await deviceCtx('alice')
|
||||
// A second, distinct device in the same registry.
|
||||
const store = createMemoryDeviceStore()
|
||||
const registry = createDeviceRegistry({ devices: store })
|
||||
const otherSigner = createDeviceLeafSigner({
|
||||
signer: ctx.ca.caSigner,
|
||||
issuer: ctx.ca.caCert.subjectName,
|
||||
caChainDer: [ctx.ca.caDer],
|
||||
sanBaseDomain: ZONE,
|
||||
trustDomain: TRUST_DOMAIN,
|
||||
devices: store,
|
||||
})
|
||||
const other = await registerDeviceLeaf(registry, store, otherSigner, 'alice')
|
||||
// Route registry holds BOTH devices; present X's cert to Y's path.
|
||||
const merged = createMemoryDeviceStore()
|
||||
const mergedReg = createDeviceRegistry({ devices: merged })
|
||||
const recX = await mergedReg.registerDevice({ accountId: randomUUID(), subdomainScope: 'alice', ecPubkeySpki: ctx.spki, attestationLevel: 'none' })
|
||||
const recY = await mergedReg.registerDevice({ accountId: randomUUID(), subdomainScope: 'alice', ecPubkeySpki: other.spki, attestationLevel: 'none' })
|
||||
void recX
|
||||
const app = appWith({ hosts: createHostRegistry({ hosts: createMemoryStores().hosts }), devices: mergedReg, renewer: ctx.renewer })
|
||||
await app.ready()
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/device/${recY.deviceId}/renew`, // Y's path
|
||||
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) }, // X's cert (SPIFFE deviceId ≠ recY)
|
||||
payload: { csr: b64Csr(ctx.csr) },
|
||||
})
|
||||
expect(res.statusCode).toBe(403)
|
||||
})
|
||||
|
||||
test('revoked device → 403', async () => {
|
||||
const ctx = await deviceCtx('alice')
|
||||
await ctx.devices.setDeviceStatus(ctx.deviceId, 'revoked')
|
||||
const app = appWith(deviceDeps(ctx))
|
||||
await app.ready()
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/device/${ctx.deviceId}/renew`,
|
||||
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
|
||||
payload: { csr: b64Csr(ctx.csr) },
|
||||
})
|
||||
expect(res.statusCode).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
// ── CP6a: bounded rate-limiter hits Map (memory-DoS guard) ─────────────────────────────────────────
|
||||
describe('CP6a createRenewRateLimiter — bounded hits Map', () => {
|
||||
test('sweeps fully-expired identities so the Map does not grow unbounded', () => {
|
||||
let t = 0
|
||||
const rl = createRenewRateLimiter({ max: 5, windowMs: 1000, maxIdentities: 2, now: () => t })
|
||||
rl.check('a')
|
||||
rl.check('b')
|
||||
expect(rl.size()).toBe(2) // at the cap
|
||||
t = 5000 // a & b are now well past their 1s window
|
||||
rl.check('c') // inserting a new identity at the cap sweeps the two stale windows first
|
||||
expect(rl.size()).toBe(1) // only 'c' survives
|
||||
})
|
||||
|
||||
test('caps tracked identities under a distinct-identity flood (all within window)', () => {
|
||||
const rl = createRenewRateLimiter({ max: 5, windowMs: 60_000, maxIdentities: 2, now: () => 0 })
|
||||
for (let i = 0; i < 100; i++) rl.check(`id-${i}`)
|
||||
expect(rl.size()).toBeLessThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ── CP6b + CP6c: body size cap + presented-cert chain/expiry verification ───────────────────────────
|
||||
describe('CP6b POST /renew — CSR length cap', () => {
|
||||
test('an over-long CSR is rejected by the body schema → 400', async () => {
|
||||
const ctx = await hostCtx('alice')
|
||||
const app = appWith(hostDeps(ctx))
|
||||
await app.ready()
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/renew',
|
||||
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
|
||||
payload: { csr: 'A'.repeat(9000) }, // > MAX_CSR_B64_LEN (8192)
|
||||
})
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CP6c POST /renew — presented current-cert chain + expiry verification', () => {
|
||||
/** Mint a host leaf (SPIFFE host SAN, chained to the frp-client-CA) with a caller-chosen validity. */
|
||||
async function mintHostLeaf(ctx: HostCtx, notBefore: Date, notAfter: Date, signer = ctx.ca.caSigner): Promise<Uint8Array> {
|
||||
const spiffe = `spiffe://relay.terminal.yaojia.wang/account/${ctx.accountId}/host/${ctx.subdomain}`
|
||||
return assembleCertificate({
|
||||
subjectPublicKey: ctx.spki,
|
||||
subject: `CN=${ctx.subdomain}`,
|
||||
issuer: ctx.ca.caCert.subjectName,
|
||||
serialNumber: Uint8Array.from([0x09]),
|
||||
notBefore,
|
||||
notAfter,
|
||||
extensions: [
|
||||
new x509.SubjectAlternativeNameExtension([
|
||||
{ type: 'dns', value: `${ctx.subdomain}.terminal.yaojia.wang` },
|
||||
{ type: 'url', value: spiffe },
|
||||
]),
|
||||
new x509.BasicConstraintsExtension(false, undefined, true),
|
||||
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
|
||||
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.clientAuth]),
|
||||
],
|
||||
signer,
|
||||
sigAlg: 'ecdsa-p256',
|
||||
})
|
||||
}
|
||||
|
||||
test('a valid current cert that CHAINS to the supplied anchor still renews → 201 (no regression)', async () => {
|
||||
const ctx = await hostCtx('alice')
|
||||
const app = appWith(hostDeps(ctx, { hostCaAnchorsDer: [ctx.ca.caDer] }))
|
||||
await app.ready()
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/renew',
|
||||
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
|
||||
payload: { csr: b64Csr(ctx.csr) },
|
||||
})
|
||||
expect(res.statusCode).toBe(201)
|
||||
})
|
||||
|
||||
test('an EXPIRED current cert (valid chain, notAfter in the past) is rejected → 401', async () => {
|
||||
const ctx = await hostCtx('alice')
|
||||
const now = Date.now()
|
||||
const expired = await mintHostLeaf(ctx, new Date(now - 2 * DAY_MS), new Date(now - DAY_MS))
|
||||
const app = appWith(hostDeps(ctx, { hostCaAnchorsDer: [ctx.ca.caDer] }))
|
||||
await app.ready()
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/renew',
|
||||
headers: { 'x-client-cert': certHeader(expired) },
|
||||
payload: { csr: b64Csr(ctx.csr) },
|
||||
})
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
test('a current cert signed by an UNTRUSTED CA (not in the anchor set) is rejected → 401', async () => {
|
||||
const ctx = await hostCtx('alice')
|
||||
const rogueCa = await makeP256Ca('rogue-CA')
|
||||
const now = Date.now()
|
||||
const rogue = await mintHostLeaf(ctx, new Date(now - 60_000), new Date(now + DAY_MS), rogueCa.caSigner)
|
||||
const app = appWith(hostDeps(ctx, { hostCaAnchorsDer: [ctx.ca.caDer] }))
|
||||
await app.ready()
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/renew',
|
||||
headers: { 'x-client-cert': certHeader(rogue) },
|
||||
payload: { csr: b64Csr(ctx.csr) },
|
||||
})
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -1,49 +1,245 @@
|
||||
/**
|
||||
* A6 (FIX H-host-3) — leaf RENEWAL upgraded off the DEV JSON-blob placeholder to REAL X.509. Proves
|
||||
* `createLeafRenewer` re-issues a tool-parseable, CA-verifiable X.509 v3 leaf (host + device) that
|
||||
* carries the SAME subdomain the registry holds (no smuggling), enforces the SAME-KEY rule (a
|
||||
* different-key CSR is rejected), and refuses a revoked identity — all by DELEGATING to the P-256
|
||||
* issuers (`frpclient-issue` / `device-issue`) that route through `assembleCertificate`. The final
|
||||
* `buildCaSigner` block (startup KMS key-policy fail-fast, INV9) is unrelated to rotation but colocated.
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { webcrypto, generateKeyPairSync, randomUUID } from 'node:crypto'
|
||||
import { parseSpiffeId } from 'relay-auth/src/agent/spiffe.js'
|
||||
import { createMemoryStores } from '../src/store/memory.js'
|
||||
import { createHostRegistry } from '../src/registry/hosts.js'
|
||||
import { createDeviceRegistry, createMemoryDeviceStore } from '../src/registry/devices.js'
|
||||
import { fingerprint } from '../src/ca/fingerprint.js'
|
||||
import { assembleCertificate } from '../src/ca/x509-assembler.js'
|
||||
import { buildCsrEc } from '../src/ca/csr-ec.js'
|
||||
import { createFrpClientLeafSigner } from '../src/ca/frpclient-issue.js'
|
||||
import { createDeviceLeafSigner } from '../src/ca/device-issue.js'
|
||||
import { createLeafRenewer } from '../src/ca/rotate.js'
|
||||
import { LeafSignError } from '../src/ca/sign.js'
|
||||
import { buildCaSigner, inProcessCaSigner, type KmsResolver } from '../src/boot/ca-wiring.js'
|
||||
import { buildCsr } from '../src/ca/csr.js'
|
||||
import { fingerprint } from '../src/ca/fingerprint.js'
|
||||
import { generateEd25519 } from '../src/util/crypto.js'
|
||||
import { DeviceLeafSignError } from '../src/ca/device-issue.js'
|
||||
import {
|
||||
buildCaSigner,
|
||||
inProcessCaSigner,
|
||||
inProcessP256CaSigner,
|
||||
type CaSigner,
|
||||
type KmsResolver,
|
||||
} from '../src/boot/ca-wiring.js'
|
||||
import { loadEnv } from '../src/env.js'
|
||||
import { bytesToBase64 } from '../src/util/bytes.js'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
const ACCOUNT = '11111111-1111-4111-8111-111111111111'
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
async function boundHost() {
|
||||
const stores = createMemoryStores()
|
||||
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||
const { publicKeyRaw, privateKey } = generateEd25519()
|
||||
const host = await hosts.bindHost({ accountId: ACCOUNT, subdomain: 'alice', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
|
||||
const renewer = createLeafRenewer({ hosts: stores.hosts, signer: inProcessCaSigner(), caChainDer: [] })
|
||||
return { stores, hosts, host, publicKeyRaw, privateKey, renewer }
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const TRUST_DOMAIN = 'terminal.yaojia.wang'
|
||||
const DNS_ZONE = 'terminal.yaojia.wang'
|
||||
const BASE_DOMAIN = 'terminal.yaojia.wang'
|
||||
|
||||
interface P256Ca {
|
||||
readonly caSigner: CaSigner
|
||||
readonly caCert: x509.X509Certificate
|
||||
readonly caDer: Uint8Array
|
||||
}
|
||||
|
||||
describe('T15 renewHostLeaf (INV14)', () => {
|
||||
test('active host + valid CSR → fresh short-TTL leaf (same subject pubkey)', async () => {
|
||||
const { host, publicKeyRaw, privateKey, renewer } = await boundHost()
|
||||
const { cert } = await renewer.renewHostLeaf(host.hostId, buildCsr(privateKey, publicKeyRaw))
|
||||
const certJson = JSON.parse(Buffer.from(cert).toString())
|
||||
const tbs = JSON.parse(Buffer.from(certJson.tbs, 'base64').toString())
|
||||
expect(tbs.subjectSpki).toBe(Buffer.from(publicKeyRaw).toString('base64'))
|
||||
expect(tbs.renewed).toBe(true)
|
||||
/** Self-signed P-256 CA (subject key == signer key) so leaves chain to a real CA. */
|
||||
async function makeP256Ca(cn: string): Promise<P256Ca> {
|
||||
const caKeys = generateKeyPairSync('ec', { namedCurve: 'P-256' })
|
||||
const caSpki = new Uint8Array(caKeys.publicKey.export({ format: 'der', type: 'spki' }))
|
||||
const caSigner = inProcessP256CaSigner(caKeys.privateKey)
|
||||
const now = Date.now()
|
||||
const caDer = await assembleCertificate({
|
||||
subjectPublicKey: caSpki,
|
||||
subject: `CN=${cn}`,
|
||||
issuer: `CN=${cn}`,
|
||||
serialNumber: Uint8Array.from([0x01]),
|
||||
notBefore: new Date(now - DAY_MS),
|
||||
notAfter: new Date(now + 365 * DAY_MS),
|
||||
extensions: [
|
||||
new x509.BasicConstraintsExtension(true, undefined, true),
|
||||
new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true),
|
||||
],
|
||||
signer: caSigner,
|
||||
sigAlg: 'ecdsa-p256',
|
||||
})
|
||||
return { caSigner, caCert: new x509.X509Certificate(caDer), caDer }
|
||||
}
|
||||
|
||||
async function importP256Public(spkiDer: Uint8Array): Promise<CryptoKey> {
|
||||
return webcrypto.subtle.importKey('spki', new Uint8Array(spkiDer), { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'])
|
||||
}
|
||||
|
||||
async function spkiOf(pub: CryptoKey): Promise<Uint8Array> {
|
||||
return new Uint8Array(await webcrypto.subtle.exportKey('spki', pub))
|
||||
}
|
||||
|
||||
// ── Host renewal setup ─────────────────────────────────────────────────────────────────────────────
|
||||
async function hostRenewFixture(subdomain = 'alice') {
|
||||
const ca = await makeP256Ca('frp-client-CA')
|
||||
const stores = createMemoryStores()
|
||||
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||
const { der: csr, keys } = await buildCsrEc(`CN=${subdomain}`)
|
||||
const spki = await spkiOf(keys.publicKey)
|
||||
const accountId = randomUUID()
|
||||
const host = await hosts.bindHost({ accountId, subdomain, agentPubkey: spki, enrollFpr: fingerprint(spki) })
|
||||
const hostSigner = createFrpClientLeafSigner({
|
||||
hosts: stores.hosts,
|
||||
signer: ca.caSigner,
|
||||
issuerName: ca.caCert.subjectName,
|
||||
caChainDer: [ca.caDer],
|
||||
trustDomain: TRUST_DOMAIN,
|
||||
dnsZone: DNS_ZONE,
|
||||
})
|
||||
// Device signer + expiry renewer are required by the renewer shape but unused in host tests — a
|
||||
// throwaway pair sharing one store.
|
||||
const throwawayDeviceStore = createMemoryDeviceStore()
|
||||
const deviceSigner = createDeviceLeafSigner({
|
||||
signer: (await makeP256Ca('device-CA')).caSigner,
|
||||
issuer: 'CN=device-CA',
|
||||
caChainDer: [],
|
||||
sanBaseDomain: BASE_DOMAIN,
|
||||
trustDomain: TRUST_DOMAIN,
|
||||
devices: throwawayDeviceStore,
|
||||
})
|
||||
const renewer = createLeafRenewer({
|
||||
hostSigner,
|
||||
deviceSigner,
|
||||
deviceExpiry: createDeviceRegistry({ devices: throwawayDeviceStore }),
|
||||
})
|
||||
return { ca, stores, hosts, host, csr, spki, keys, accountId, renewer }
|
||||
}
|
||||
|
||||
describe('A6 renewHostLeaf → REAL X.509 (FIX H-host-3)', () => {
|
||||
test('active host + same-key CSR → a re-parseable, CA-verifiable X.509 leaf carrying the SAME subdomain', async () => {
|
||||
const { ca, host, csr, accountId, renewer } = await hostRenewFixture('alice')
|
||||
const { cert, caChain, notAfter } = await renewer.renewHostLeaf(host.hostId, host.agentPubkey, csr)
|
||||
|
||||
// (1) it is a REAL X.509 cert — not a JSON blob — that re-parses.
|
||||
expect(() => new x509.X509Certificate(cert)).not.toThrow()
|
||||
const leaf = new x509.X509Certificate(cert)
|
||||
// (2) it chains to the frp-client-CA (signature verifies under the CA key).
|
||||
const caPub = await importP256Public(ca.caSigner.publicKeyRaw)
|
||||
expect(await leaf.verify({ publicKey: caPub, signatureOnly: true })).toBe(true)
|
||||
// (3) SAN carries the registry subdomain as the dNSName enforcement key + the host SPIFFE URI.
|
||||
const san = leaf.getExtension(x509.SubjectAlternativeNameExtension)
|
||||
const names = san!.names.toJSON()
|
||||
expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' })
|
||||
const uri = names.find((n) => n.type === 'url')!.value
|
||||
expect(parseSpiffeId(uri)).toEqual({ accountId, id: 'alice', kind: 'host' })
|
||||
// (4) renewal surfaces a real future expiry + the CA chain.
|
||||
expect(notAfter.getTime()).toBeGreaterThan(Date.now())
|
||||
expect(caChain).toEqual([ca.caDer])
|
||||
})
|
||||
|
||||
test('CSR proof-of-possession fails → reject even for an active host', async () => {
|
||||
const { host, publicKeyRaw, renewer } = await boundHost()
|
||||
const forged = new Uint8Array(96)
|
||||
forged.set(publicKeyRaw, 0)
|
||||
forged.set(randomBytes(64), 32) // garbage signature
|
||||
await expect(renewer.renewHostLeaf(host.hostId, forged)).rejects.toBeInstanceOf(LeafSignError)
|
||||
test('a CSR with a DIFFERENT key (key-swap attempt) → rejected (MVP same-key)', async () => {
|
||||
const { host, renewer } = await hostRenewFixture('alice')
|
||||
const { der: otherCsr } = await buildCsrEc('CN=alice') // fresh, different P-256 key
|
||||
await expect(renewer.renewHostLeaf(host.hostId, host.agentPubkey, otherCsr)).rejects.toBeInstanceOf(LeafSignError)
|
||||
})
|
||||
|
||||
test('revoked host cannot renew (INV12+INV14 loop)', async () => {
|
||||
const { hosts, host, publicKeyRaw, privateKey, renewer } = await boundHost()
|
||||
test('a revoked host cannot renew (INV12+INV14 loop)', async () => {
|
||||
const { hosts, host, csr, renewer } = await hostRenewFixture('alice')
|
||||
await hosts.setHostStatus(host.hostId, 'revoked')
|
||||
await expect(renewer.renewHostLeaf(host.hostId, buildCsr(privateKey, publicKeyRaw))).rejects.toBeInstanceOf(LeafSignError)
|
||||
await expect(renewer.renewHostLeaf(host.hostId, host.agentPubkey, csr)).rejects.toBeInstanceOf(LeafSignError)
|
||||
})
|
||||
|
||||
test('the re-issued subdomain comes from the REGISTRY, never the CSR subject (no smuggling)', async () => {
|
||||
const { host, keys, renewer } = await hostRenewFixture('alice')
|
||||
// A malicious renewal CSR over the SAME key but with a foreign subject label.
|
||||
const evilCsr = new Uint8Array(
|
||||
(
|
||||
await x509.Pkcs10CertificateRequestGenerator.create({
|
||||
name: 'CN=bob.terminal.yaojia.wang',
|
||||
keys,
|
||||
signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' },
|
||||
})
|
||||
).rawData,
|
||||
)
|
||||
const { cert } = await renewer.renewHostLeaf(host.hostId, host.agentPubkey, evilCsr)
|
||||
const names = new x509.X509Certificate(cert).getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON()
|
||||
expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' }) // still alice
|
||||
expect(names).not.toContainEqual({ type: 'dns', value: 'bob.terminal.yaojia.wang' })
|
||||
})
|
||||
})
|
||||
|
||||
// ── Device renewal setup ────────────────────────────────────────────────────────────────────────────
|
||||
async function deviceRenewFixture(subdomainScope = 'alice', now?: () => number) {
|
||||
const ca = await makeP256Ca('device-CA')
|
||||
const store = createMemoryDeviceStore()
|
||||
const registry = createDeviceRegistry({ devices: store, ...(now ? { now } : {}) })
|
||||
const { der: csr, keys } = await buildCsrEc('CN=web-terminal-device')
|
||||
const spki = await spkiOf(keys.publicKey)
|
||||
const record = await registry.registerDevice({
|
||||
accountId: randomUUID(),
|
||||
subdomainScope,
|
||||
ecPubkeySpki: spki,
|
||||
attestationLevel: 'none',
|
||||
})
|
||||
const deviceSigner = createDeviceLeafSigner({
|
||||
signer: ca.caSigner,
|
||||
issuer: ca.caCert.subjectName,
|
||||
caChainDer: [ca.caDer],
|
||||
sanBaseDomain: BASE_DOMAIN,
|
||||
trustDomain: TRUST_DOMAIN,
|
||||
devices: store,
|
||||
})
|
||||
// Host signer is required by the renewer shape but unused in device tests — a throwaway.
|
||||
const hostSigner = createFrpClientLeafSigner({
|
||||
hosts: createMemoryStores().hosts,
|
||||
signer: (await makeP256Ca('frp-client-CA')).caSigner,
|
||||
issuerName: 'CN=frp-client-CA',
|
||||
caChainDer: [],
|
||||
trustDomain: TRUST_DOMAIN,
|
||||
dnsZone: DNS_ZONE,
|
||||
})
|
||||
const renewer = createLeafRenewer({ hostSigner, deviceSigner, deviceExpiry: registry })
|
||||
return { ca, store, registry, record, csr, spki, keys, renewer }
|
||||
}
|
||||
|
||||
describe('A6 renewDeviceLeaf → REAL X.509 (FIX H-host-3)', () => {
|
||||
test('active device + same-key CSR → a CA-verifiable X.509 leaf carrying the SAME subdomainScope', async () => {
|
||||
const { ca, record, csr, renewer } = await deviceRenewFixture('alice')
|
||||
const { cert, caChain, notAfter } = await renewer.renewDeviceLeaf(record.deviceId, csr)
|
||||
|
||||
const leaf = new x509.X509Certificate(cert)
|
||||
const caPub = await importP256Public(ca.caSigner.publicKeyRaw)
|
||||
expect(await leaf.verify({ publicKey: caPub, signatureOnly: true })).toBe(true)
|
||||
const names = leaf.getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON()
|
||||
expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' })
|
||||
const uri = names.find((n) => n.type === 'url')!.value
|
||||
expect(parseSpiffeId(uri)).toEqual({ accountId: record.accountId, id: record.deviceId, kind: 'device' })
|
||||
expect(notAfter.getTime()).toBeGreaterThan(Date.now())
|
||||
expect(caChain).toEqual([ca.caDer])
|
||||
})
|
||||
|
||||
test('a CSR with a DIFFERENT key → rejected (MVP same-key)', async () => {
|
||||
const { record, renewer } = await deviceRenewFixture('alice')
|
||||
const { der: otherCsr } = await buildCsrEc('CN=web-terminal-device')
|
||||
await expect(renewer.renewDeviceLeaf(record.deviceId, otherCsr)).rejects.toBeInstanceOf(DeviceLeafSignError)
|
||||
})
|
||||
|
||||
test('a revoked device cannot renew', async () => {
|
||||
const { registry, record, csr, renewer } = await deviceRenewFixture('alice')
|
||||
await registry.setDeviceStatus(record.deviceId, 'revoked')
|
||||
await expect(renewer.renewDeviceLeaf(record.deviceId, csr)).rejects.toBeInstanceOf(DeviceLeafSignError)
|
||||
})
|
||||
|
||||
test('renewing TWICE across an advanced clock EXTENDS validity — the 2nd notAfter is later than the 1st', async () => {
|
||||
// Regression for the H finding: the device signer stamps record.notAfter (set once at enroll), so
|
||||
// without a per-renewal bump both renewals returned a byte-identical expiry — eventually in the
|
||||
// PAST. Advance the registry clock a comfortable margin (X.509 notAfter is second-resolution) so
|
||||
// the increase is unambiguous in the DER, then assert the second expiry strictly exceeds the first.
|
||||
let clock = Date.now()
|
||||
const { record, csr, renewer } = await deviceRenewFixture('alice', () => clock)
|
||||
const first = await renewer.renewDeviceLeaf(record.deviceId, csr)
|
||||
clock += 2 * DAY_MS
|
||||
const second = await renewer.renewDeviceLeaf(record.deviceId, csr)
|
||||
expect(second.notAfter.getTime()).toBeGreaterThan(first.notAfter.getTime())
|
||||
// And the extension is REAL (roughly the clock advance), not a rounding artefact.
|
||||
expect(second.notAfter.getTime() - first.notAfter.getTime()).toBeGreaterThanOrEqual(DAY_MS)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
117
control-plane/test/session.test.ts
Normal file
117
control-plane/test/session.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* A4 (session subsystem, FIX C-native-1) — the minimal device:enroll token mint/verify + the login
|
||||
* stub seam. Proves: a minted token round-trips through relay-auth's REAL §4.3 verifier; a token
|
||||
* lacking the 'enroll' right is rejected (403); a wrong audience is rejected (401); an expired token
|
||||
* is rejected (401); and the single-tenant login stub resolves a credential → accountId (deny-by-default
|
||||
* on a wrong / empty credential).
|
||||
*/
|
||||
import { describe, test, expect, beforeEach } from 'vitest'
|
||||
import { configureVerifyKey } from 'relay-auth'
|
||||
import { resetVerifyKeyForTest } from 'relay-auth/src/config/keys.js'
|
||||
import { generateEd25519KeyPair } from 'relay-auth/src/crypto/ed25519.js'
|
||||
import { signPaseto } from 'relay-auth/src/crypto/paseto.js'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import {
|
||||
mintDeviceEnrollToken,
|
||||
verifyDeviceEnrollToken,
|
||||
loginToAccountId,
|
||||
requireEnrollRight,
|
||||
DeviceEnrollAuthError,
|
||||
DEVICE_ENROLL_AUD,
|
||||
} from '../src/auth/session.js'
|
||||
|
||||
const NOW = 1_700_000_000
|
||||
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
/** 43-char base64url placeholder (satisfies the shared §4.3 verifier's `cnf.jkt` requirement). */
|
||||
function jkt(): string {
|
||||
return Buffer.from(randomBytes(32)).toString('base64url')
|
||||
}
|
||||
|
||||
let signingKey: CryptoKey
|
||||
|
||||
beforeEach(async () => {
|
||||
resetVerifyKeyForTest()
|
||||
const pair = await generateEd25519KeyPair()
|
||||
await configureVerifyKey(pair.publicKey)
|
||||
signingKey = pair.privateKey
|
||||
})
|
||||
|
||||
describe('A4 device:enroll session token', () => {
|
||||
test('mint → verify round-trips and yields the accountId (rights:[enroll])', async () => {
|
||||
const raw = await mintDeviceEnrollToken(ACCOUNT_A, { signingKey, now: NOW })
|
||||
const { accountId } = await verifyDeviceEnrollToken(raw, { now: NOW + 5 })
|
||||
expect(accountId).toBe(ACCOUNT_A)
|
||||
})
|
||||
|
||||
test('a token missing the enroll right is rejected (403)', async () => {
|
||||
// Mint an otherwise-valid device-enroll-aud token but with rights:['attach'] (no enroll).
|
||||
const body = {
|
||||
sub: ACCOUNT_A,
|
||||
aud: DEVICE_ENROLL_AUD,
|
||||
host: 'device-enroll',
|
||||
rights: ['attach'],
|
||||
iat: NOW,
|
||||
exp: NOW + 600,
|
||||
jti: 'jti-noenroll',
|
||||
cnf: { jkt: jkt() },
|
||||
}
|
||||
const raw = await signPaseto(body, signingKey)
|
||||
await expect(verifyDeviceEnrollToken(raw, { now: NOW + 5 })).rejects.toMatchObject({ status: 403 })
|
||||
})
|
||||
|
||||
test('a token with the wrong audience is rejected (401)', async () => {
|
||||
const raw = await mintDeviceEnrollToken(ACCOUNT_A, { signingKey, now: NOW, aud: 'not-device-enroll' })
|
||||
await expect(verifyDeviceEnrollToken(raw, { now: NOW + 5 })).rejects.toMatchObject({ status: 401 })
|
||||
})
|
||||
|
||||
test('an expired token is rejected (401)', async () => {
|
||||
const raw = await mintDeviceEnrollToken(ACCOUNT_A, { signingKey, now: NOW, ttlSeconds: 60 })
|
||||
await expect(verifyDeviceEnrollToken(raw, { now: NOW + 120 })).rejects.toMatchObject({ status: 401 })
|
||||
})
|
||||
|
||||
test('a garbage token is rejected (401), uniform reject', async () => {
|
||||
await expect(verifyDeviceEnrollToken('not-a-token', { now: NOW })).rejects.toBeInstanceOf(DeviceEnrollAuthError)
|
||||
})
|
||||
|
||||
test('requireEnrollRight throws 403 without enroll, passes with it', () => {
|
||||
const base = { sub: ACCOUNT_A, aud: DEVICE_ENROLL_AUD, host: 'x', iat: NOW, exp: NOW + 1, jti: 'j' }
|
||||
expect(() => requireEnrollRight({ ...base, rights: ['attach'] })).toThrow(DeviceEnrollAuthError)
|
||||
expect(() => requireEnrollRight({ ...base, rights: ['enroll'] })).not.toThrow()
|
||||
})
|
||||
|
||||
test('ttl is minutes-scale (separate from the 30–60s connect clamp)', async () => {
|
||||
// A 10-minute token must still verify well past the 60s connect clamp horizon.
|
||||
const raw = await mintDeviceEnrollToken(ACCOUNT_A, { signingKey, now: NOW })
|
||||
const { accountId } = await verifyDeviceEnrollToken(raw, { now: NOW + 5 * 60 })
|
||||
expect(accountId).toBe(ACCOUNT_A)
|
||||
})
|
||||
})
|
||||
|
||||
describe('A4 loginToAccountId stub seam (single-tenant MVP)', () => {
|
||||
test('resolves an operator credential → accountId', () => {
|
||||
const acct = loginToAccountId('op-secret', { operatorCredential: 'op-secret', accountId: ACCOUNT_A })
|
||||
expect(acct).toBe(ACCOUNT_A)
|
||||
})
|
||||
|
||||
test('rejects a wrong credential (401)', () => {
|
||||
expect(() => loginToAccountId('wrong', { operatorCredential: 'op-secret', accountId: ACCOUNT_A })).toThrow(
|
||||
DeviceEnrollAuthError,
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects an empty credential (401)', () => {
|
||||
expect(() => loginToAccountId('', { operatorCredential: 'op-secret', accountId: ACCOUNT_A })).toThrow(
|
||||
DeviceEnrollAuthError,
|
||||
)
|
||||
})
|
||||
|
||||
test('supports an injected resolver seam (real login layer later)', () => {
|
||||
const acct = loginToAccountId('pairing-xyz', { resolve: (c) => (c === 'pairing-xyz' ? ACCOUNT_A : null) })
|
||||
expect(acct).toBe(ACCOUNT_A)
|
||||
})
|
||||
|
||||
test('rejects when not configured (deny-by-default)', () => {
|
||||
expect(() => loginToAccountId('anything', {})).toThrow(DeviceEnrollAuthError)
|
||||
})
|
||||
})
|
||||
302
control-plane/test/x509-assembler.test.ts
Normal file
302
control-plane/test/x509-assembler.test.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* A1 acceptance (FIX C-1) — the single X.509 issuance primitive. Proves that a leaf assembled by
|
||||
* DER-encoding the TBS ourselves and signing the SERIALIZED TBS via `CaSigner.sign` (the KMS
|
||||
* boundary) is a REAL, tool-parseable, signature-verifiable X.509 v3 certificate for BOTH the
|
||||
* Ed25519 and ECDSA-P256 CA families — and that the dNSName+URI SAN the A3 nginx njs will parse
|
||||
* round-trips byte-correct.
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { AsnConvert } from '@peculiar/asn1-schema'
|
||||
import { Certificate } from '@peculiar/asn1-x509'
|
||||
import { webcrypto, generateKeyPairSync, randomBytes } from 'node:crypto'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
assembleCertificate,
|
||||
normalizeEcdsaSignatureToDer,
|
||||
type AssembleCertificateInput,
|
||||
} from '../src/ca/x509-assembler.js'
|
||||
import { inProcessCaSigner, inProcessP256CaSigner, type CaSigner } from '../src/boot/ca-wiring.js'
|
||||
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const SAN_DNS = 'alice.terminal.yaojia.wang'
|
||||
const SAN_URI = 'spiffe://relay.example.com/account/a1/host/alice'
|
||||
|
||||
/** WebCrypto key pair (the `CryptoKeyPair` global is not in this project's TS lib set). */
|
||||
type KeyPair = { readonly publicKey: CryptoKey; readonly privateKey: CryptoKey }
|
||||
|
||||
async function genP256(): Promise<KeyPair> {
|
||||
return (await webcrypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])) as unknown as KeyPair
|
||||
}
|
||||
|
||||
const ED25519_SPKI_PREFIX = Uint8Array.from([
|
||||
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
||||
])
|
||||
|
||||
function derToPem(der: Uint8Array, label = 'CERTIFICATE'): string {
|
||||
const b64 = Buffer.from(der).toString('base64').match(/.{1,64}/g)?.join('\n') ?? ''
|
||||
return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----\n`
|
||||
}
|
||||
|
||||
function leafExtensions(): x509.Extension[] {
|
||||
return [
|
||||
new x509.SubjectAlternativeNameExtension([
|
||||
{ type: 'dns', value: SAN_DNS },
|
||||
{ type: 'url', value: SAN_URI },
|
||||
]),
|
||||
new x509.BasicConstraintsExtension(false, undefined, true),
|
||||
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
|
||||
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.clientAuth]),
|
||||
]
|
||||
}
|
||||
|
||||
async function importEd25519Public(raw: Uint8Array): Promise<CryptoKey> {
|
||||
const spki = new Uint8Array(ED25519_SPKI_PREFIX.length + raw.length)
|
||||
spki.set(ED25519_SPKI_PREFIX, 0)
|
||||
spki.set(raw, ED25519_SPKI_PREFIX.length)
|
||||
return webcrypto.subtle.importKey('spki', spki, { name: 'Ed25519' }, true, ['verify'])
|
||||
}
|
||||
|
||||
async function importP256Public(spkiDer: Uint8Array): Promise<CryptoKey> {
|
||||
// Copy into a fresh ArrayBuffer-backed view so it satisfies BufferSource (not ArrayBufferLike).
|
||||
return webcrypto.subtle.importKey('spki', new Uint8Array(spkiDer), { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'])
|
||||
}
|
||||
|
||||
async function ed25519SubjectSpki(): Promise<Uint8Array> {
|
||||
const { publicKey } = generateKeyPairSync('ed25519')
|
||||
return new Uint8Array(publicKey.export({ format: 'der', type: 'spki' }))
|
||||
}
|
||||
|
||||
function baseInput(overrides: Partial<AssembleCertificateInput>): AssembleCertificateInput {
|
||||
const now = Date.now()
|
||||
return {
|
||||
subjectPublicKey: new Uint8Array(0),
|
||||
subject: 'CN=alice',
|
||||
issuer: 'CN=test-CA',
|
||||
serialNumber: randomBytes(16),
|
||||
notBefore: new Date(now - 60_000),
|
||||
notAfter: new Date(now + DAY_MS),
|
||||
extensions: leafExtensions(),
|
||||
signer: inProcessCaSigner(),
|
||||
sigAlg: 'ed25519',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('x509-assembler — gate (a): assembled leaves re-parse as X.509', () => {
|
||||
test('Ed25519 leaf re-parses via new x509.X509Certificate without throwing', async () => {
|
||||
const der = await assembleCertificate(baseInput({ subjectPublicKey: await ed25519SubjectSpki() }))
|
||||
expect(() => new x509.X509Certificate(der)).not.toThrow()
|
||||
})
|
||||
|
||||
test('ECDSA-P256 leaf re-parses via new x509.X509Certificate without throwing', async () => {
|
||||
const subject = await genP256()
|
||||
const der = await assembleCertificate(baseInput({ subjectPublicKey: subject.publicKey, signer: inProcessP256CaSigner(), sigAlg: 'ecdsa-p256' }))
|
||||
expect(() => new x509.X509Certificate(der)).not.toThrow()
|
||||
expect(new x509.X509Certificate(der).subject).toBe('CN=alice')
|
||||
})
|
||||
})
|
||||
|
||||
describe('x509-assembler — gate (b): re-parsed leaf signature verifies against the CA public key', () => {
|
||||
test('Ed25519 leaf verifies against the Ed25519 CA public key', async () => {
|
||||
const ca = inProcessCaSigner()
|
||||
const der = await assembleCertificate(baseInput({ subjectPublicKey: await ed25519SubjectSpki(), signer: ca, sigAlg: 'ed25519' }))
|
||||
const leaf = new x509.X509Certificate(der)
|
||||
const caPub = await importEd25519Public(ca.publicKeyRaw)
|
||||
expect(await leaf.verify({ publicKey: caPub, signatureOnly: true })).toBe(true)
|
||||
})
|
||||
|
||||
test('ECDSA-P256 leaf verifies against the P-256 CA public key', async () => {
|
||||
const ca = inProcessP256CaSigner()
|
||||
const subject = await genP256()
|
||||
const der = await assembleCertificate(baseInput({ subjectPublicKey: subject.publicKey, signer: ca, sigAlg: 'ecdsa-p256' }))
|
||||
const leaf = new x509.X509Certificate(der)
|
||||
const caPub = await importP256Public(ca.publicKeyRaw)
|
||||
expect(await leaf.verify({ publicKey: caPub, signatureOnly: true })).toBe(true)
|
||||
})
|
||||
|
||||
test('a leaf does NOT verify against a different CA key (negative)', async () => {
|
||||
const ca = inProcessP256CaSigner()
|
||||
const subject = await genP256()
|
||||
const der = await assembleCertificate(baseInput({ subjectPublicKey: subject.publicKey, signer: ca, sigAlg: 'ecdsa-p256' }))
|
||||
const leaf = new x509.X509Certificate(der)
|
||||
const otherCaPub = await importP256Public(inProcessP256CaSigner().publicKeyRaw)
|
||||
expect(await leaf.verify({ publicKey: otherCaPub, signatureOnly: true })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('x509-assembler — gate (c): dNSName + URI SAN round-trips byte-correct', () => {
|
||||
test('Ed25519 leaf SAN carries both the dNSName and the URI', async () => {
|
||||
const der = await assembleCertificate(baseInput({ subjectPublicKey: await ed25519SubjectSpki() }))
|
||||
const san = new x509.X509Certificate(der).getExtension(x509.SubjectAlternativeNameExtension)
|
||||
const names = san!.names.toJSON()
|
||||
expect(names).toContainEqual({ type: 'dns', value: SAN_DNS })
|
||||
expect(names).toContainEqual({ type: 'url', value: SAN_URI })
|
||||
})
|
||||
|
||||
test('ECDSA-P256 leaf SAN carries both the dNSName and the URI', async () => {
|
||||
const subject = await genP256()
|
||||
const der = await assembleCertificate(baseInput({ subjectPublicKey: subject.publicKey, signer: inProcessP256CaSigner(), sigAlg: 'ecdsa-p256' }))
|
||||
const san = new x509.X509Certificate(der).getExtension(x509.SubjectAlternativeNameExtension)
|
||||
const names = san!.names.toJSON()
|
||||
expect(names).toContainEqual({ type: 'dns', value: SAN_DNS })
|
||||
expect(names).toContainEqual({ type: 'url', value: SAN_URI })
|
||||
})
|
||||
})
|
||||
|
||||
describe('x509-assembler — X.509 structural invariants', () => {
|
||||
test('tbsCertificate.signature OID equals certificate.signatureAlgorithm OID (identical)', async () => {
|
||||
const der = await assembleCertificate(baseInput({ subjectPublicKey: await ed25519SubjectSpki() }))
|
||||
const cert = AsnConvert.parse(der.buffer.slice(der.byteOffset, der.byteOffset + der.byteLength) as ArrayBuffer, Certificate)
|
||||
expect(cert.tbsCertificate.signature.algorithm).toBe(cert.signatureAlgorithm.algorithm)
|
||||
expect(cert.signatureAlgorithm.algorithm).toBe('1.3.101.112')
|
||||
})
|
||||
|
||||
test('ECDSA-P256 signatureAlgorithm OID is ecdsa-with-SHA256', async () => {
|
||||
const subject = await genP256()
|
||||
const der = await assembleCertificate(baseInput({ subjectPublicKey: subject.publicKey, signer: inProcessP256CaSigner(), sigAlg: 'ecdsa-p256' }))
|
||||
const cert = AsnConvert.parse(der.buffer.slice(der.byteOffset, der.byteOffset + der.byteLength) as ArrayBuffer, Certificate)
|
||||
expect(cert.tbsCertificate.signature.algorithm).toBe('1.2.840.10045.4.3.2')
|
||||
expect(cert.signatureAlgorithm.algorithm).toBe('1.2.840.10045.4.3.2')
|
||||
})
|
||||
|
||||
test('a high-bit serial is emitted as a positive INTEGER (0x00-prefixed content octets)', async () => {
|
||||
const highBit = Uint8Array.from([0x80, 0x01, 0x02, 0x03])
|
||||
const der = await assembleCertificate(baseInput({ subjectPublicKey: await ed25519SubjectSpki(), serialNumber: highBit }))
|
||||
// Inspect the raw INTEGER content octets: the assembler MUST prepend 0x00 so a leading 0x80 is
|
||||
// never read as a negative integer. (@peculiar's serialNumber getter strips it for display.)
|
||||
const cert = AsnConvert.parse(der.buffer.slice(der.byteOffset, der.byteOffset + der.byteLength) as ArrayBuffer, Certificate)
|
||||
const serialBytes = new Uint8Array(cert.tbsCertificate.serialNumber)
|
||||
expect(Array.from(serialBytes)).toEqual([0x00, 0x80, 0x01, 0x02, 0x03])
|
||||
})
|
||||
|
||||
test('subject public key accepted as BOTH a CryptoKey and as SPKI DER', async () => {
|
||||
const kp = await genP256()
|
||||
const spkiDer = new Uint8Array(await webcrypto.subtle.exportKey('spki', kp.publicKey))
|
||||
const ca = inProcessP256CaSigner()
|
||||
const fromKey = await assembleCertificate(baseInput({ subjectPublicKey: kp.publicKey, signer: ca, sigAlg: 'ecdsa-p256' }))
|
||||
const fromDer = await assembleCertificate(baseInput({ subjectPublicKey: spkiDer, signer: ca, sigAlg: 'ecdsa-p256' }))
|
||||
// Both embed the SAME subjectPublicKeyInfo.
|
||||
expect(new x509.X509Certificate(fromKey).publicKey.rawData.byteLength).toBe(new x509.X509Certificate(fromDer).publicKey.rawData.byteLength)
|
||||
expect(Buffer.from(new x509.X509Certificate(fromKey).publicKey.rawData).equals(Buffer.from(new x509.X509Certificate(fromDer).publicKey.rawData))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeEcdsaSignatureToDer — both signer output shapes', () => {
|
||||
test('raw P1363 (64 bytes) is converted to a valid DER ECDSA-Sig-Value', async () => {
|
||||
const kp = generateKeyPairSync('ec', { namedCurve: 'P-256' })
|
||||
const { sign } = await import('node:crypto')
|
||||
const raw = new Uint8Array(sign('sha256', Buffer.from('hello'), { key: kp.privateKey, dsaEncoding: 'ieee-p1363' }))
|
||||
expect(raw.length).toBe(64)
|
||||
const der = normalizeEcdsaSignatureToDer(raw)
|
||||
expect(der[0]).toBe(0x30) // SEQUENCE
|
||||
// openssl-shape signature verifies against the same key (proves the r,s survived intact).
|
||||
const { verify } = await import('node:crypto')
|
||||
expect(verify('sha256', Buffer.from('hello'), { key: kp.publicKey, dsaEncoding: 'der' }, Buffer.from(der))).toBe(true)
|
||||
})
|
||||
|
||||
test('an already-DER ECDSA-Sig-Value passes through unchanged', async () => {
|
||||
const kp = generateKeyPairSync('ec', { namedCurve: 'P-256' })
|
||||
const { sign } = await import('node:crypto')
|
||||
const der = new Uint8Array(sign('sha256', Buffer.from('world'), { key: kp.privateKey, dsaEncoding: 'der' }))
|
||||
const out = normalizeEcdsaSignatureToDer(der)
|
||||
expect(Buffer.from(out).equals(Buffer.from(der))).toBe(true)
|
||||
})
|
||||
|
||||
test('garbage that is neither P1363 nor DER throws (fail loud at issuance)', () => {
|
||||
expect(() => normalizeEcdsaSignatureToDer(Uint8Array.from([1, 2, 3, 4, 5]))).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('x509-assembler — Ed25519 signatureValue length guard (CP2)', () => {
|
||||
/** A CaSigner whose `sign` returns a wrong-length "Ed25519" signature (truncated / malformed). */
|
||||
function fixedLenEd25519Signer(len: number): CaSigner {
|
||||
return { publicKeyRaw: new Uint8Array(32), async sign() { return new Uint8Array(len) } }
|
||||
}
|
||||
|
||||
test('a signer returning fewer than 64 bytes is rejected at issuance (not embedded)', async () => {
|
||||
await expect(
|
||||
assembleCertificate(
|
||||
baseInput({ subjectPublicKey: await ed25519SubjectSpki(), signer: fixedLenEd25519Signer(63), sigAlg: 'ed25519' }),
|
||||
),
|
||||
).rejects.toThrow(/64 bytes/)
|
||||
})
|
||||
|
||||
test('a signer returning more than 64 bytes is rejected too', async () => {
|
||||
await expect(
|
||||
assembleCertificate(
|
||||
baseInput({ subjectPublicKey: await ed25519SubjectSpki(), signer: fixedLenEd25519Signer(65), sigAlg: 'ed25519' }),
|
||||
),
|
||||
).rejects.toThrow(/64 bytes/)
|
||||
})
|
||||
|
||||
test('a real 64-byte Ed25519 signature still issues fine (no false positive)', async () => {
|
||||
const der = await assembleCertificate(baseInput({ subjectPublicKey: await ed25519SubjectSpki() }))
|
||||
expect(() => new x509.X509Certificate(der)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('x509-assembler — gate (e): openssl parses & verifies the chain', () => {
|
||||
function hasOpenssl(): boolean {
|
||||
try {
|
||||
execFileSync('openssl', ['version'], { stdio: 'ignore' })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
test('openssl x509 -text parses the P-256 leaf and openssl verify OKs it against the CA', async () => {
|
||||
if (!hasOpenssl()) {
|
||||
// openssl absent — gates (a)-(c) already prove real X.509; note and skip the tool check.
|
||||
expect(true).toBe(true)
|
||||
return
|
||||
}
|
||||
// Self-signed P-256 CA (subject key == signer key) so openssl can build a full chain.
|
||||
const caKeys = generateKeyPairSync('ec', { namedCurve: 'P-256' })
|
||||
const caSpki = new Uint8Array(caKeys.publicKey.export({ format: 'der', type: 'spki' }))
|
||||
const caSigner: CaSigner = inProcessP256CaSigner(caKeys.privateKey)
|
||||
const now = Date.now()
|
||||
const caDer = await assembleCertificate({
|
||||
subjectPublicKey: caSpki,
|
||||
subject: 'CN=Test P256 CA',
|
||||
issuer: 'CN=Test P256 CA',
|
||||
serialNumber: Uint8Array.from([0x01]),
|
||||
notBefore: new Date(now - DAY_MS),
|
||||
notAfter: new Date(now + 365 * DAY_MS),
|
||||
extensions: [
|
||||
new x509.BasicConstraintsExtension(true, undefined, true),
|
||||
new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true),
|
||||
],
|
||||
signer: caSigner,
|
||||
sigAlg: 'ecdsa-p256',
|
||||
})
|
||||
|
||||
const subject = await genP256()
|
||||
const leafDer = await assembleCertificate(baseInput({ subjectPublicKey: subject.publicKey, issuer: 'CN=Test P256 CA', signer: caSigner, sigAlg: 'ecdsa-p256' }))
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'x509asm-'))
|
||||
try {
|
||||
const caPem = join(dir, 'ca.pem')
|
||||
const leafPem = join(dir, 'leaf.pem')
|
||||
writeFileSync(caPem, derToPem(caDer))
|
||||
writeFileSync(leafPem, derToPem(leafDer))
|
||||
|
||||
const text = execFileSync('openssl', ['x509', '-in', leafPem, '-noout', '-text'], { encoding: 'utf8' })
|
||||
expect(text).toContain('Signature Algorithm: ecdsa-with-SHA256')
|
||||
expect(text).toContain(SAN_DNS)
|
||||
expect(text).toContain(SAN_URI)
|
||||
|
||||
const verifyOut = execFileSync('openssl', ['verify', '-CAfile', caPem, leafPem], { encoding: 'utf8' })
|
||||
expect(verifyOut).toContain('OK')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user