feat(tunnel): zero-touch tunnel enrollment — control-plane PKI, host agent, iOS, nginx isolation

Customers install one command / log in once; hardware-generated keys never leave the
device; CSRs return certs + subdomain; frpc + base-app run as durable services. No .p12,
no manual cert import. Implements the MVP fast-path of docs/PLAN_TUNNEL_AUTOMATION.md.

Control-plane / PKI (control-plane/):
- ca/x509-assembler.ts: single KMS-signed real X.509 issuance primitive (Ed25519 + P-256)
- ca/csr-ec.ts: P-256 PKCS#10 proof-of-possession (verifyCsrPoPEc) + CSR-key routing
- ca/frpclient-issue.ts, ca/device-issue.ts: P-256 frp-client + device leaf signers
- ca/rotate.ts + api/renew.ts: real-X.509 /renew + /device/:id/renew (mTLS current cert)
- registry/devices.ts: device registry + per-account cap/rate-limit
- auth/session.ts: device:enroll capability token mint/verify
- api/device-enroll.ts: POST /device/enroll (ownership-gated, deny-by-default)
- pairing/native-redeem.ts + shared gateAndConsumePairingCode; api/provision.ts native arm
- boot/native-ca.ts + main.ts: wire two P-256 CAs + issuers + routers (dev / KMS fail-fast)

Contracts: relay-contracts enroll right; relay-auth SPIFFE /device/ arm + spiffeIdFor(kind)

Host agent (agent/):
- transport/frpcToml.ts; provision/frpcBinary.ts + untar.ts (verify-download + traversal-safe extract)
- keys P-256 keygen/CSR/loadIdentity; service two-unit install + BIND_HOST loopback S-GATE
- net/loopbackLiteral.ts strict guard; health/probe.ts + transport/frpSupervise.ts; cli pair --install

iOS (ios/Packages/ClientTLS): SecureEnclaveKey + CertificateSigningRequest + DeviceEnrollmentClient
+ Keychain enroll refactor (SecKey/Security.framework end-to-end, avoids the -25300 trap)

Isolation (deploy/nginx): njs/getCertSub.js SAN parser + zone-anchored map -> 403

Verified: 758 tests green (control-plane 246, agent 267, relay-auth 133, relay-contracts 85,
iOS ClientTLS 27), all tsc clean; real nginx+njs docker 403/200/400; Swift CSR accepted by
the real control-plane verifier; frpc extract byte-identical to `tar -xO`. Cross-validation
caught + fixed 5 real defects (1 critical, 4 high). Remaining = infra (KMS, nginx deploy,
VPS frps, physical iPhone) per PROGRESS_LOG runbook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2026-07-10 16:11:13 +02:00
parent 31054450fc
commit e7f3bd05f0
79 changed files with 9920 additions and 385 deletions

View File

@@ -1,12 +1,20 @@
/**
* CLI entrypoint — PLAN_RELAY_AGENT T5. `pair | run | status | install | uninstall`.
* All side effects (network/FS/tunnel) are injected via `CliDeps` so tests avoid real IO.
* CLI entrypoint — PLAN_RELAY_AGENT T5, extended for the native tunnel (PLAN_TUNNEL_AUTOMATION B5).
* `pair | run | status | install | uninstall`. All side effects (network/FS/tunnel/provision) are
* injected via `CliDeps` so `runCli` stays pure and offline-testable.
*
* `pair <CODE> --install` is the NATIVE zero-touch onboard: P-256 keygen (FIX H-host-2) → CSR →
* POST /enroll → provision the pinned frpc binary → write frpc.toml → install BOTH units (base-app
* + agent, base-app env routed to the base-app unit; the agent unit supervises frpc — NOT the old
* relay `runTunnel` rendezvous) → print `https://<sub>.terminal.<domain>`.
*
* `status` prints host_id/subdomain/online ONLY — never key/cert material (INV9).
*/
import type { AgentConfig } from './config/agentConfig.js'
import type { AgentIdentity } from './keys/identity.js'
import type { Keystore } from './keys/keystore.js'
import type { InstallOptions } from './service/install.js'
import { assertNativeZone, type InstallOptions } from './service/install.js'
import { subdomainOrigin } from './service/originConfig.js'
import type { EnrollResult } from 'relay-contracts'
export type CliCommand = 'pair' | 'run' | 'status' | 'install' | 'uninstall'
@@ -25,12 +33,38 @@ export class CliUsageError extends Error {
}
}
/** The non-secret enrollment result the native onboard needs (host id + assigned subdomain). */
export interface NativeEnrollResult {
readonly hostId: string
readonly subdomain: string
}
export interface CliDeps {
loadConfig(): AgentConfig
openKeystore(stateDir: string): Keystore
/** Ed25519 identity for the legacy relay path. */
generateIdentity(): AgentIdentity
/** P-256 identity for the native frp-client key (FIX H-host-2); private key never leaves the host. */
generateP256Identity(): AgentIdentity
/** Legacy relay redemption (Ed25519, E2E rendezvous). */
redeem(cfg: AgentConfig, code: string, id: AgentIdentity, ks: Keystore): Promise<EnrollResult>
/** Native enroll: build the P-256 CSR, POST /enroll, store the returned cert; return ids only. */
enrollNative(
cfg: AgentConfig,
code: string,
id: AgentIdentity,
ks: Keystore,
): Promise<NativeEnrollResult>
/** Download + verify + place the pinned frpc binary (B3); returns its path. */
provisionFrpc(cfg: AgentConfig): Promise<string>
/** Write the native `frpc.toml` for `subdomain` (base-app env is routed via installService). */
writeFrpcConfig(cfg: AgentConfig, subdomain: string): void
/** True iff a written native `frpc.toml` exists in `cfg.stateDir` (native-onboard signal). */
nativeConfigExists(cfg: AgentConfig): boolean
/** Legacy relay run-loop (Ed25519 WS rendezvous, T10 backoff + T9 heartbeat). */
runTunnel(cfg: AgentConfig, ks: Keystore): Promise<number>
/** Native run-loop: supervise the pinned frpc child (restart-on-exit backoff + health probe). */
superviseFrpc(cfg: AgentConfig, ks: Keystore): Promise<number>
/** Resolve per-host install inputs (S0 env incl. loopback BIND_HOST, tunnel origin) from env/flags. */
resolveInstallOptions(): InstallOptions
installService(cfg: AgentConfig, options: InstallOptions): Promise<void>
@@ -63,23 +97,60 @@ export function parseArgs(argv: readonly string[]): CliArgs {
return args
}
/**
* Native zero-touch onboard for `pair <CODE> --install` (B5). Order is load-bearing: keygen(P-256)
* → enroll (CSR + POST /enroll) → provision frpc (so the binary exists before the agent unit
* starts) → write frpc.toml → install BOTH units (which start them) → print the tunnel URL.
*/
async function pairInstallNative(
code: string,
cfg: AgentConfig,
ks: Keystore,
deps: CliDeps,
): Promise<number> {
const options = deps.resolveInstallOptions()
if (!options.domain) {
throw new CliUsageError(
'native install requires TUNNEL_DOMAIN — the origin is https://<sub>.terminal.<domain>',
)
}
assertNativeZone(options.zone) // FIX L-host-zone: native ⇒ `terminal`
const id = deps.generateP256Identity() // keygen (P-256, FIX H-host-2)
ks.saveIdentity(id)
const enroll = await deps.enrollNative(cfg, code, id, ks) // CSR → POST /enroll → store cert
await deps.provisionFrpc(cfg) // pinned frpc binary on disk before the service starts (B3)
deps.writeFrpcConfig(cfg, enroll.subdomain) // frpc.toml
await deps.installService(cfg, options) // base-app + agent units; base-app env routed → started
deps.print(subdomainOrigin(enroll.subdomain, options.domain, options.zone))
return 0
}
/** Dispatch a parsed CliArgs; returns a process exit code (0 = success). */
export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
const cfg = deps.loadConfig()
const ks = deps.openKeystore(cfg.stateDir)
switch (args.command) {
case 'pair': {
if (args.flags['install']) return pairInstallNative(args.code!, cfg, ks, deps)
// Legacy relay pair (Ed25519 rendezvous, no install).
const id = ks.loadIdentity() ?? deps.generateIdentity()
ks.saveIdentity(id)
const enroll = await deps.redeem(cfg, args.code!, id, ks)
deps.print(`paired: host ${enroll.hostId} subdomain ${enroll.subdomain}`)
if (args.flags['install']) await deps.installService(cfg, deps.resolveInstallOptions())
return 0
}
case 'run': {
if (ks.loadIdentity() === null || ks.loadCert() === null) {
const id = ks.loadIdentity()
if (id === null || ks.loadCert() === null) {
throw new CliUsageError('not enrolled — run `web-terminal-agent pair <CODE>` first')
}
// Native onboard = a P-256 frp-client identity + a written frpc.toml. Supervise frpc as a
// child (restart-on-exit backoff + health probe) instead of the legacy Ed25519 relay tunnel.
if (id.alg === 'p256' && deps.nativeConfigExists(cfg)) {
return deps.superviseFrpc(cfg, ks)
}
return deps.runTunnel(cfg, ks)
}
case 'status': {

View File

@@ -1,30 +1,55 @@
/**
* CliDeps factory — PLAN_RELAY_PHASE1 C2. Wires the abstract `CliDeps` seams (consumed by
* `runCli`) to their real implementations: env-driven config, the on-disk keystore, Ed25519
* identity generation, §4.5 pairing redemption, the supervised tunnel, and OS service install.
* All side effects live here so `cli.ts`/`runCli` stay pure and unit-testable.
* CliDeps factory — PLAN_RELAY_PHASE1 C2, extended for the native tunnel (PLAN_TUNNEL_AUTOMATION B5).
* Wires the abstract `CliDeps` seams (consumed by `runCli`) to their real implementations: env-driven
* config, the on-disk keystore, identity generation (Ed25519 + P-256), §4.5 pairing redemption, the
* native enroll + frpc provisioning + frpc.toml writer, the supervised tunnel, and the two-unit OS
* service install. All side effects live here so `cli.ts`/`runCli` stay pure and unit-testable.
*/
import { execFile } from 'node:child_process'
import { mkdirSync, writeFileSync } from 'node:fs'
import { X509Certificate } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { homedir, userInfo } from 'node:os'
import { dirname } from 'node:path'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { CliDeps } from '../cli.js'
import type { CliDeps, NativeEnrollResult } from '../cli.js'
import type { AgentConfig } from '../config/agentConfig.js'
import { loadAgentConfig } from '../config/agentConfig.js'
import { openKeystore } from '../keys/keystore.js'
import { generateIdentity } from '../keys/identity.js'
import { generateIdentity, generateP256Identity } from '../keys/identity.js'
import type { AgentIdentity } from '../keys/identity.js'
import type { Keystore } from '../keys/keystore.js'
import { redeemPairingCode } from '../enroll/pair.js'
import { runTunnel } from '../transport/runTunnel.js'
import { buildNativeFrpcToml } from '../transport/frpcToml.js'
import { superviseFrpc } from '../transport/frpSupervise.js'
import { provisionFrpc } from '../provision/frpcBinary.js'
import {
probeLoopbackBaseApp,
renderHealthStatus,
runHealthProbe,
startHealthMonitor,
} from '../health/probe.js'
import { createLogger } from '../log/logger.js'
import { ensureAllowedOrigin } from '../service/originConfig.js'
import {
buildInstallOptions,
detectPlatform,
NATIVE_ORIGIN_ZONE,
installService as installServiceUnit,
uninstallService as uninstallServiceUnit,
type InstallDeps,
type ServicePlatform,
} from '../service/install.js'
/** Keystore file names in `stateDir` (kept in lockstep with `keys/keystore.ts`). */
const KEYSTORE_CERT = 'agent.cert.pem'
const KEYSTORE_KEY = 'agent.key.pem'
const KEYSTORE_CA = 'agent.ca.pem'
const FRPC_TOML = 'frpc.toml'
const FRPC_LOG = 'frpc.log'
const BASE_APP_ENV_FILE = 'base-app.env'
const DEFAULT_LOCAL_PORT = 3000
/** Resolve this process's own executable path (the bundled `dist/cli.js`) for the service unit. */
function selfBinPath(): string {
return fileURLToPath(import.meta.url)
@@ -59,13 +84,134 @@ function requirePlatform(): ServicePlatform {
return platform
}
/** Parse a positive-integer PORT from the env (falls back to the base-app default 3000). */
function resolveLocalPort(): number {
const raw = process.env.PORT
const port = raw ? Number.parseInt(raw, 10) : NaN
return Number.isInteger(port) && port > 0 ? port : DEFAULT_LOCAL_PORT
}
/** Native enroll: build the P-256 CSR + POST /enroll (via the frozen redeem flow), store the cert. */
async function enrollNative(
cfg: AgentConfig,
code: string,
id: AgentIdentity,
ks: Keystore,
): Promise<NativeEnrollResult> {
const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks)
return { hostId: enroll.hostId, subdomain: enroll.subdomain }
}
/**
* Write the native `frpc.toml` into `stateDir`, pointing frpc at this host's keystore cert/key/CA and
* the loopback base app. Also materializes the base-app ALLOWED_ORIGINS env file. The frps shared
* token comes from `FRP_AUTH_TOKEN` (deploy secret; never logged). NOTE: the frpc binary run/e2e is
* pending the B3 tar.gz extraction; this seam only emits the config.
*/
function writeFrpcConfig(cfg: AgentConfig, subdomain: string): void {
const domain = process.env.TUNNEL_DOMAIN
const toml = buildNativeFrpcToml({
subdomain,
localPort: resolveLocalPort(),
authToken: process.env.FRP_AUTH_TOKEN ?? '',
certFile: join(cfg.stateDir, KEYSTORE_CERT),
keyFile: join(cfg.stateDir, KEYSTORE_KEY),
trustedCaFile: join(cfg.stateDir, KEYSTORE_CA),
})
mkdirSync(cfg.stateDir, { recursive: true })
writeFileSync(join(cfg.stateDir, FRPC_TOML), toml, { mode: 0o600 })
if (domain) {
ensureAllowedOrigin(join(cfg.stateDir, BASE_APP_ENV_FILE), subdomain, domain, undefined, NATIVE_ORIGIN_ZONE)
}
}
/** The stored frp-client leaf's `notAfter`, or null if no cert/parse failure (non-secret metadata). */
function certNotAfter(ks: Keystore): Date | null {
const cert = ks.loadCert()
if (cert === null) return null
try {
return new X509Certificate(cert.certPem).validToDate
} catch {
return null
}
}
/**
* The single frpc log path in `stateDir`. Used by BOTH the supervisor's file-logging spawn (writer)
* and `readFrpcLog` (reader) so the health probe can never scan a different file than frpc writes.
*/
export function frpcLogPath(stateDir: string): string {
return join(stateDir, FRPC_LOG)
}
/** Read the accumulated frpc log (empty string if not yet written) for the proxy-started scan. */
export function readFrpcLog(stateDir: string): string {
const path = frpcLogPath(stateDir)
if (!existsSync(path)) return ''
try {
return readFileSync(path, 'utf8')
} catch {
return ''
}
}
/**
* Native run-loop (B4/H4): supervise the pinned frpc child with restart-on-exit backoff while a
* periodic health probe (frpc alive, base-app loopback reachable, proxy-started, cert-not-expiring)
* logs NON-SECRET status only (INV9). Resolves when the supervisor stops (SIGTERM/SIGINT).
*/
function superviseNative(cfg: AgentConfig, ks: Keystore): Promise<number> {
const logger = createLogger('info')
const binPath = join(cfg.stateDir, 'bin', 'frpc')
const tomlPath = join(cfg.stateDir, FRPC_TOML)
// Tee the frpc child's stdout/stderr into `<stateDir>/frpc.log` (the SAME path `readFrpcLog`
// scans below) so the proxy-started health sub-check has real content — without this wiring the
// log stays empty and `HealthReport.healthy` can never be true (B4/H4 goal).
const handle = superviseFrpc(binPath, tomlPath, { logger, logFile: frpcLogPath(cfg.stateDir) })
const port = resolveLocalPort()
const monitor = startHealthMonitor(
() =>
runHealthProbe({
isFrpcAlive: () => handle.isChildAlive(),
probeBaseApp: () => probeLoopbackBaseApp(port, (url) => fetch(url)),
readFrpcLog: () => readFrpcLog(cfg.stateDir),
certNotAfter: () => certNotAfter(ks),
now: () => new Date(),
}),
(report) => {
// INV9: only non-secret identifiers (subdomain/host id/expiry date) + boolean flags are logged.
const ids = { subdomain: cfg.subdomain, hostId: cfg.hostId, certNotAfter: certNotAfter(ks) }
for (const line of renderHealthStatus(ids, report)) logger.log('info', line)
},
)
const onSignal = (): void => {
void handle.stop()
}
process.once('SIGTERM', onSignal)
process.once('SIGINT', onSignal)
return handle.done.finally(() => monitor.stop())
}
/** Build the concrete CliDeps used by the real CLI entrypoint. */
export function createCliDeps(): CliDeps {
return {
loadConfig: () => loadAgentConfig(process.env),
openKeystore: (stateDir) => openKeystore(stateDir),
generateIdentity: () => generateIdentity(),
generateP256Identity: () => generateP256Identity(),
redeem: (cfg: AgentConfig, code, id, ks) => redeemPairingCode(cfg.enrollUrl, code, id, ks),
enrollNative: (cfg, code, id, ks) => enrollNative(cfg, code, id, ks),
provisionFrpc: async (cfg) => {
const result = await provisionFrpc({
platform: process.platform,
arch: process.arch,
binDir: join(cfg.stateDir, 'bin'),
})
return result.binPath
},
writeFrpcConfig: (cfg, subdomain) => writeFrpcConfig(cfg, subdomain),
nativeConfigExists: (cfg) => existsSync(join(cfg.stateDir, FRPC_TOML)),
superviseFrpc: (cfg, ks) => superviseNative(cfg, ks),
runTunnel: async (cfg, ks) => {
const handle = await runTunnel(cfg, ks)
const onSignal = (): void => {

View File

@@ -9,6 +9,7 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import { z } from 'zod'
import { isLoopbackHostLiteral } from '../net/loopbackLiteral.js'
export interface AgentConfig {
readonly relayUrl: string
@@ -19,9 +20,12 @@ export interface AgentConfig {
readonly hostId: string | null
}
const LOOPBACK_HOSTNAMES: readonly string[] = ['localhost', '127.0.0.1', '::1', '[::1]']
/** True iff `url` is ws:// to a loopback host (127.0.0.0/8, localhost, or ::1). */
/**
* True iff `url` is ws:// to a loopback host (a well-formed 127.0.0.0/8 IPv4 literal, localhost, or
* ::1). Uses the shared strict check so a crafted suffixed hostname such as
* `ws://127.0.0.1.attacker.example.com:3000` — which the outbound dial would DNS-resolve and connect
* to wherever it points — is REJECTED, closing the anti-SSRF bypass (not merely `startsWith('127.')`).
*/
export function isLoopbackWsUrl(url: string): boolean {
let parsed: URL
try {
@@ -30,8 +34,7 @@ export function isLoopbackWsUrl(url: string): boolean {
return false
}
if (parsed.protocol !== 'ws:') return false
const host = parsed.hostname
return LOOPBACK_HOSTNAMES.includes(host) || host.startsWith('127.')
return isLoopbackHostLiteral(parsed.hostname)
}
function hasScheme(url: string, scheme: string): boolean {

View File

@@ -52,9 +52,11 @@ const OID = 0x06
const UTF8_STRING = 0x0c
const CONTEXT_0 = 0xa0
// OID 2.5.4.3 (commonName) and 1.3.101.112 (Ed25519) as pre-encoded DER value bytes.
// OID 2.5.4.3 (commonName), 1.3.101.112 (Ed25519), 1.2.840.10045.4.3.2 (ecdsa-with-SHA256) as
// pre-encoded DER value bytes.
const OID_CN = Uint8Array.from([0x55, 0x04, 0x03])
const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70])
const OID_ECDSA_WITH_SHA256 = Uint8Array.from([0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x02])
/** SubjectPublicKeyInfo DER for a raw Ed25519 public key (fixed 44-byte structure). */
function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
@@ -63,6 +65,24 @@ function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
return tlv(SEQUENCE, concat([algId, pubBits]))
}
/**
* SubjectPublicKeyInfo bytes for the CSR. For P-256, `id.publicKey` IS already the full EC SPKI DER
* (built by `keys/identity.ts`), so it is embedded verbatim; for Ed25519 the raw 32-byte key is
* wrapped into the fixed SPKI structure.
*/
function spkiFor(id: AgentIdentity): Uint8Array {
return id.alg === 'p256' ? id.publicKey : spkiFromRawEd25519(id.publicKey)
}
/**
* The signatureAlgorithm AlgorithmIdentifier: `SEQUENCE { OID }` (no parameters — RFC 5758 §3.2 for
* ecdsa-with-SHA256, and Ed25519 likewise omits parameters).
*/
function sigAlgFor(id: AgentIdentity): Uint8Array {
const oid = id.alg === 'p256' ? OID_ECDSA_WITH_SHA256 : OID_ED25519
return tlv(SEQUENCE, tlv(OID, oid))
}
/** X.501 Name with a single CN=<subject> RDN. */
function nameFromCn(cn: string): Uint8Array {
const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))]))
@@ -77,18 +97,20 @@ function toPem(der: Uint8Array, label: string): string {
}
/**
* Build a PKCS#10 CSR (PEM) for `id` with subject CN=`subject`, signed by the Ed25519 key.
* The private key is used in-process only; never serialized into the output (INV4).
* Build a PKCS#10 CSR (PEM) for `id` with subject CN=`subject`, signed by the identity's key. The
* Ed25519 and P-256 (ecdsa-with-SHA256, FIX H-host-2) paths share this one encoder — only the SPKI
* and the signatureAlgorithm differ. The private key is used in-process only; never serialized into
* the output (INV4).
*/
export function buildCsr(id: AgentIdentity, subject: string): string {
const version = tlv(INTEGER, Uint8Array.from([0x00]))
const name = nameFromCn(subject)
const spki = spkiFromRawEd25519(id.publicKey)
const spki = spkiFor(id)
const attributes = tlv(CONTEXT_0, new Uint8Array(0)) // [0] IMPLICIT empty SET OF Attribute
const requestInfo = tlv(SEQUENCE, concat([version, name, spki, attributes]))
const signature = id.sign(requestInfo) // Ed25519 over CertificationRequestInfo
const sigAlg = tlv(SEQUENCE, tlv(OID, OID_ED25519))
const signature = id.sign(requestInfo) // over CertificationRequestInfo, per id.alg
const sigAlg = sigAlgFor(id)
const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature]))
const csr = tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits]))

182
agent/src/health/probe.ts Normal file
View File

@@ -0,0 +1,182 @@
/**
* Native-tunnel health probe — TASK B4/H4 (PLAN_TUNNEL_AUTOMATION §3.2 / §5, INV9).
*
* Reports four independent sub-checks that together say whether this host's native frp tunnel is
* actually serving:
* (a) frpcAlive — the supervised frpc child process is running;
* (b) baseAppReachable — the loopback base app answers `GET http://127.0.0.1:PORT` (loopback ONLY);
* (c) proxyStarted — frpc logged a "start proxy success" line (control channel + proxy up);
* (d) certFresh — the frp-client leaf's `notAfter` is beyond the renewal window (not expiring).
*
* Every side effect is an INJECTABLE seam (process-liveness checker, loopback HTTP probe, log
* scanner, clock) so the logic is pure and offline-testable. The status renderer emits ONLY
* non-secret identifiers (subdomain, host id, cert expiry date, boolean flags) — NEVER keys, certs,
* tokens, or CSRs (INV9). No `console.log`.
*/
/** Default renewal window: 8h — one third of the 24h host frp-client TTL (renew at ~2/3 TTL). */
export const DEFAULT_CERT_RENEW_WINDOW_MS = 8 * 60 * 60 * 1000
/** frpc emits this line on the control channel once a proxy is registered and forwarding. */
export const FRPC_START_SUCCESS_RE = /start proxy success/i
/** Loopback host the base-app probe targets — hardcoded so the probe can never reach off-host. */
export const LOOPBACK_PROBE_HOST = '127.0.0.1'
const MIN_PORT = 1
const MAX_PORT = 65535
/** The four independent sub-checks plus the derived overall verdict. */
export interface HealthReport {
/** The supervised frpc child is running. */
readonly frpcAlive: boolean
/** `GET http://127.0.0.1:PORT` returned a response (base app is up on loopback). */
readonly baseAppReachable: boolean
/** frpc logged "start proxy success" (the tunnel proxy is registered). */
readonly proxyStarted: boolean
/** The frp-client cert's `notAfter` is beyond the renewal window (not near expiry). */
readonly certFresh: boolean
/** True iff all four sub-checks pass. */
readonly healthy: boolean
}
/** Injectable side effects the probe consumes; each is independently faked in tests. */
export interface HealthProbeSeams {
/** Process-liveness checker (the supervisor exposes whether its frpc child is alive). */
readonly isFrpcAlive: () => boolean
/** Loopback HTTP probe of the base app; resolves true iff it answered ok. */
readonly probeBaseApp: () => Promise<boolean>
/** Returns the accumulated frpc stdout/log text to scan for the success line. */
readonly readFrpcLog: () => string
/** The stored frp-client leaf's `notAfter`, or null if no cert is available. */
readonly certNotAfter: () => Date | null
/** Current time (injected for deterministic expiry tests). */
readonly now: () => Date
}
export interface HealthProbeConfig {
/** Certs within this many ms of `notAfter` are "near expiry" (default 8h). */
readonly renewWindowMs?: number
}
/** Pure: true iff the frpc log contains a "start proxy success" line. */
export function frpcProxyStarted(logText: string): boolean {
return FRPC_START_SUCCESS_RE.test(logText)
}
/** Pure: true iff `notAfter` is strictly beyond the renewal window from `now` (not near expiry). */
export function certIsFresh(notAfter: Date | null, now: Date, renewWindowMs: number): boolean {
if (notAfter === null) return false
return notAfter.getTime() - now.getTime() > renewWindowMs
}
/** Minimal shape of a fetch response the loopback probe cares about. */
export interface LoopbackResponse {
readonly ok: boolean
}
/** Injectable loopback HTTP fetch (real wiring passes global `fetch`). */
export type LoopbackFetch = (url: string) => Promise<LoopbackResponse>
/**
* Probe the loopback base app with `GET http://127.0.0.1:PORT/`. The host is hardcoded loopback, so
* the probe can never reach an off-host target (anti-SSRF). Any thrown/rejected fetch — or a
* non-integer/out-of-range port — resolves to `false` rather than propagating (a probe never throws).
*/
export async function probeLoopbackBaseApp(port: number, fetchImpl: LoopbackFetch): Promise<boolean> {
if (!Number.isInteger(port) || port < MIN_PORT || port > MAX_PORT) return false
const url = `http://${LOOPBACK_PROBE_HOST}:${port}/`
try {
const res = await fetchImpl(url)
return res.ok
} catch {
return false
}
}
/** Run all four sub-checks and derive the overall verdict. Never throws (each seam is guarded). */
export async function runHealthProbe(
seams: HealthProbeSeams,
config: HealthProbeConfig = {},
): Promise<HealthReport> {
const renewWindowMs = config.renewWindowMs ?? DEFAULT_CERT_RENEW_WINDOW_MS
const frpcAlive = seams.isFrpcAlive()
const baseAppReachable = await seams.probeBaseApp()
const proxyStarted = frpcProxyStarted(seams.readFrpcLog())
const certFresh = certIsFresh(seams.certNotAfter(), seams.now(), renewWindowMs)
const healthy = frpcAlive && baseAppReachable && proxyStarted && certFresh
return { frpcAlive, baseAppReachable, proxyStarted, certFresh, healthy }
}
/** Non-secret identifiers safe to print in `status` (INV9): NO key/cert/token/CSR material. */
export interface StatusIdentifiers {
readonly subdomain: string | null
readonly hostId: string | null
readonly certNotAfter: Date | null
}
/**
* Render `status` lines from non-secret identifiers + a health report (INV9). Emits the subdomain,
* host id, cert EXPIRY DATE (never the cert bytes), and the boolean sub-check flags — never any key,
* cert, token, or CSR material.
*/
export function renderHealthStatus(
ids: StatusIdentifiers,
report: HealthReport,
): readonly string[] {
return [
`subdomain: ${ids.subdomain ?? '(none)'}`,
`host_id: ${ids.hostId ?? '(none)'}`,
`cert_expiry: ${ids.certNotAfter ? ids.certNotAfter.toISOString() : '(unknown)'}`,
`frpc_alive: ${report.frpcAlive}`,
`base_app_reachable: ${report.baseAppReachable}`,
`proxy_started: ${report.proxyStarted}`,
`cert_fresh: ${report.certFresh}`,
`healthy: ${report.healthy}`,
]
}
/** Default periodic health-monitor interval (30s). */
export const DEFAULT_HEALTH_INTERVAL_MS = 30_000
/** Minimal injectable interval timer (fake-timer-testable). */
export interface IntervalTimer {
setInterval(cb: () => void, ms: number): unknown
clearInterval(handle: unknown): void
}
const realIntervalTimer: IntervalTimer = {
setInterval: (cb, ms) => setInterval(cb, ms),
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
}
/** Handle to a running health monitor. */
export interface HealthMonitor {
stop(): void
}
/**
* Start a periodic health monitor: every `intervalMs`, run `probe()` and hand the report to
* `onReport` (the run-loop wires this to a redacting logger — non-secret lines only). A rejected
* probe is swallowed (a monitor must never crash the supervisor). `stop()` clears the interval.
*/
export function startHealthMonitor(
probe: () => Promise<HealthReport>,
onReport: (report: HealthReport) => void,
opts: { intervalMs?: number; timer?: IntervalTimer } = {},
): HealthMonitor {
const intervalMs = opts.intervalMs ?? DEFAULT_HEALTH_INTERVAL_MS
const timer = opts.timer ?? realIntervalTimer
const handle = timer.setInterval(() => {
void probe()
.then(onReport)
.catch(() => {
/* a probe failure is itself an unhealthy signal; never let it crash the monitor */
})
}, intervalMs)
return {
stop(): void {
timer.clearInterval(handle)
},
}
}

View File

@@ -1,20 +1,37 @@
/**
* Agent identity — PLAN_RELAY_AGENT T3 (INV4: the private key NEVER leaves the host).
*
* Ed25519 keypair generated locally with node:crypto. `AgentIdentity` exposes ONLY the public
* key, the §4.2 enroll fingerprint, and an in-process `sign()`; there is NO API that returns or
* serializes the private key. The raw private key material is held in a module-private closure.
* Two key algorithms share one `AgentIdentity` shape (discriminated by `alg`):
* - `ed25519` — the relay E2E rendezvous path (unchanged).
* - `p256` — the native-tunnel HOST frp-client key (FIX H-host-2): the `frp-client-CA` is P-256,
* so the host key, its CSR (ECDSA-with-SHA256), and its leaf are all P-256.
* `AgentIdentity` exposes ONLY the public key, the §4.2 enroll fingerprint, and an in-process
* `sign()`; there is NO API that returns or serializes the private key. The raw private key material
* is held in a module-private closure and, for P-256, NEVER leaves the host (only the pubkey + CSR do).
*/
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto'
import type { KeyObject } from 'node:crypto'
import { encodeBase64UrlBytes } from 'relay-contracts'
/** Which key algorithm an identity carries. Drives CSR SPKI + signatureAlgorithm encoding. */
export type KeyAlg = 'ed25519' | 'p256'
export interface AgentIdentity {
/** Ed25519 raw 32-byte public key → stored in host registry (§4.2 agent_pubkey). */
/** Which key algorithm this identity uses (`ed25519` = relay path, `p256` = native frp-client). */
readonly alg: KeyAlg
/**
* The registry-stored public key bytes (§4.2 agent_pubkey):
* - `ed25519`: the raw 32-byte public key;
* - `p256`: the EC SubjectPublicKeyInfo DER (what the control-plane P-256 gate compares).
*/
readonly publicKey: Uint8Array
/** §4.2 enroll_fpr: base64url(SHA-256(publicKey)) — pinned by the browser E2E TOFU (§4.4). */
readonly enrollFpr: string
/** Ed25519 signature over `message`, using the in-process private key. Never returns the key. */
/**
* Signature over `message` using the in-process private key (never returns the key):
* - `ed25519`: a raw 64-byte Ed25519 signature;
* - `p256`: a DER `ECDSA-Sig-Value` (ecdsa-with-SHA256) — the PKCS#10 signatureValue shape.
*/
sign(message: Uint8Array): Uint8Array
/** Export the PRIVATE key as PKCS#8 PEM — for on-disk 0600 persistence ONLY (keystore). */
exportPrivatePkcs8Pem(): string
@@ -39,6 +56,7 @@ function buildIdentity(privateKey: KeyObject, publicKey: KeyObject): AgentIdenti
const rawPub = rawPublicKey(publicKey)
const enrollFpr = computeEnrollFpr(rawPub)
return {
alg: 'ed25519',
publicKey: rawPub,
enrollFpr,
sign(message: Uint8Array): Uint8Array {
@@ -53,19 +71,61 @@ function buildIdentity(privateKey: KeyObject, publicKey: KeyObject): AgentIdenti
}
}
/**
* P-256 identity (FIX H-host-2). `publicKey` is the EC SubjectPublicKeyInfo DER (the exact bytes the
* control-plane frp-client gate compares against the registry); `sign` produces a DER `ECDSA-Sig-Value`
* over the SHA-256 digest — the PKCS#10 / X.509 signatureValue shape. The private key never leaves
* this closure (INV4); only the SPKI + CSR are emitted off-host.
*/
function buildP256Identity(privateKey: KeyObject, publicKey: KeyObject): AgentIdentity {
const spkiDer = new Uint8Array(publicKey.export({ type: 'spki', format: 'der' }))
const enrollFpr = computeEnrollFpr(spkiDer)
return {
alg: 'p256',
publicKey: spkiDer,
enrollFpr,
sign(message: Uint8Array): Uint8Array {
// ecdsa-with-SHA256 → DER ECDSA-Sig-Value (node's default dsaEncoding is 'der').
return new Uint8Array(sign('sha256', message, privateKey))
},
exportPrivatePkcs8Pem(): string {
return privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
},
privateKeyObject(): KeyObject {
return privateKey
},
}
}
/** Generate a fresh Ed25519 identity; the private key is held in-memory only (INV4). */
export function generateIdentity(): AgentIdentity {
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
return buildIdentity(privateKey, publicKey)
}
/** Reconstruct an identity from a stored PKCS#8 PEM private key (keystore load path). */
/**
* Generate a fresh EC P-256 identity for the native-tunnel host frp-client key (FIX H-host-2). The
* private key is held in-memory only (INV4) and NEVER serialized off-host; only the pubkey + CSR leave.
*/
export function generateP256Identity(): AgentIdentity {
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' })
return buildP256Identity(privateKey, publicKey)
}
/** Reconstruct an Ed25519 identity from a stored PKCS#8 PEM private key (keystore load path). */
export function identityFromPrivatePem(pem: string): AgentIdentity {
const privateKey = createPrivateKey(pem)
const publicKey = createPublicKey(privateKey)
return buildIdentity(privateKey, publicKey)
}
/** Reconstruct a P-256 identity from a stored PKCS#8 PEM private key (keystore load path). */
export function p256IdentityFromPrivatePem(pem: string): AgentIdentity {
const privateKey = createPrivateKey(pem)
const publicKey = createPublicKey(privateKey)
return buildP256Identity(privateKey, publicKey)
}
/** Verify an Ed25519 signature against a raw public key — helper for tests/handshake checks. */
export function verifySignature(
publicKey: Uint8Array,

View File

@@ -12,8 +12,9 @@ import {
writeFileSync,
} from 'node:fs'
import { join } from 'node:path'
import { createPrivateKey } from 'node:crypto'
import type { AgentIdentity } from './identity.js'
import { identityFromPrivatePem } from './identity.js'
import { identityFromPrivatePem, p256IdentityFromPrivatePem } from './identity.js'
const SECRET_MODE = 0o600
const DIR_MODE = 0o700
@@ -54,6 +55,32 @@ function ensureDir(stateDir: string): void {
}
}
/**
* Reconstruct an `AgentIdentity` from a stored PKCS#8 PEM, branching on the key's algorithm
* discriminant (the PKCS#8 AlgorithmIdentifier OID, surfaced as `asymmetricKeyType`): an Ed25519
* key → the relay-path builder; an EC P-256 key → the native frp-client builder (EC SPKI publicKey).
* A saved P-256 identity therefore round-trips as `alg: 'p256'` with a usable signing key, while
* Ed25519 stays byte-identical. Any other algorithm is a hard error (never silently mislabeled).
*/
function identityFromStoredPem(pem: string): AgentIdentity {
const key = createPrivateKey(pem)
const alg = key.asymmetricKeyType
if (alg === 'ed25519') return identityFromPrivatePem(pem)
if (alg === 'ec') {
// An `ec` key alone is not proof of P-256 — a P-384/secp256k1 key also reports `ec`. The native
// frp-client path is P-256 ONLY, so assert the named curve before treating it as such; any other
// curve is a hard error (never silently mislabeled as a usable P-256 identity).
const curve = key.asymmetricKeyDetails?.namedCurve
if (curve !== 'prime256v1') {
throw new Error(
`unsupported stored EC identity curve: ${curve ?? 'unknown'} (only prime256v1/P-256 is supported)`,
)
}
return p256IdentityFromPrivatePem(pem)
}
throw new Error(`unsupported stored identity key algorithm: ${alg ?? 'unknown'}`)
}
function writeSecret(path: string, data: string | Uint8Array): void {
writeFileSync(path, data, { mode: SECRET_MODE })
// Enforce 0600 even if a prior umask/file left it wider.
@@ -80,7 +107,7 @@ export function openKeystore(stateDir: string): Keystore {
throw new KeystoreError(`failed to read identity key: ${(err as Error).message}`)
}
try {
return identityFromPrivatePem(pem)
return identityFromStoredPem(pem)
} catch (err) {
throw new KeystoreError(`corrupt identity key file: ${(err as Error).message}`)
}

View File

@@ -0,0 +1,29 @@
/**
* Strict loopback-literal check — the single source of truth for both S-GATE-style guards:
* - `service/install.ts` isLoopbackBindHost (BIND_HOST S-GATE, FIX C-host-1, CRITICAL)
* - `config/agentConfig.ts` isLoopbackWsUrl (localTargetUrl anti-SSRF)
*
* Both previously used `value.startsWith('127.')`, which treats an arbitrary suffixed HOSTNAME
* such as `127.0.0.1.attacker.example.com` or `127.evil.net` as loopback. Because Node resolves
* non-literal hosts via DNS before bind()/dial, that prefix match let a crafted value defeat the
* exact invariant the guard exists to enforce. This module fails closed: it accepts a value ONLY
* when it is an EXACT loopback literal — `localhost`, `::1`/`[::1]`, or a fully-parsed IPv4 address
* in 127.0.0.0/8 with NO trailing characters. Anything else (a hostname, a partial IP, `0.0.0.0`,
* `::`) is rejected.
*/
import { isIPv4 } from 'node:net'
/** Non-IPv4 loopback literals accepted verbatim (localhost + the IPv6 loopback, with/without brackets). */
const LOOPBACK_LITERALS: ReadonlySet<string> = new Set(['localhost', '::1', '[::1]'])
/**
* True iff `host` is an EXACT loopback literal: `localhost`, `::1`, `[::1]`, or a well-formed IPv4
* address in 127.0.0.0/8 (the whole string must parse as a dotted-quad — no trailing label). A
* suffixed hostname like `127.0.0.1.attacker.example.com` is NOT loopback and returns false.
*/
export function isLoopbackHostLiteral(host: string): boolean {
if (LOOPBACK_LITERALS.has(host)) return true
// isIPv4 requires the FULL string to be a dotted-quad, so no trailing hostname label can slip
// through; the first-octet check then confines it to the 127.0.0.0/8 loopback block.
return isIPv4(host) && host.split('.')[0] === '127'
}

View File

@@ -0,0 +1,259 @@
/**
* Pinned frpc binary provisioner — TASK B3 (PLAN_TUNNEL_AUTOMATION §3.2 / §5 supply-chain).
*
* Mirrors the `dist/buildBinary.ts` verify-download discipline: detect OS/arch, select a PINNED
* release `{ version, url, sha256 }`, download the artifact TO DISK (a temp file), verify its
* SHA-256 against the pin BEFORE the binary is placed or executed, then place it atomically
* (temp -> rename) with the exec bit. On hash mismatch NOTHING is placed and the temp is removed.
*
* Non-negotiable (§5): never `curl | sh` a streamed secret, never disable TLS verification (no `-k`)
* — the default fetch enforces `https:` and there is no insecure escape hatch.
*
* frp's GitHub releases ship a `.tar.gz` whose payload is `frp_<ver>_<os>_<arch>/{frpc,frps,...}` —
* a host cannot exec a `.tar.gz`. So AFTER the archive hash matches its pin (and only then) the bytes
* are handed to the path-traversal-hardened extractor in `untar.ts`, which pulls out ONLY the inner
* `frpc`; that verified binary is what gets placed. Extraction failure (no `frpc`, corrupt gzip/tar,
* unsafe entry name, oversize) places nothing and throws `FrpcProvisionError`.
*
* The network fetch and the filesystem are INJECTABLE seams (`ProvisionFrpcDeps`) so tests exercise
* URL/arch selection, hash-match extraction+placement, hash-mismatch rejection, extraction failures,
* and unsupported-platform errors with no network access.
*/
import { createHash, randomBytes } from 'node:crypto'
import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { extractFrpcBinary } from './untar.js'
export type FrpcPlatform = 'darwin-arm64' | 'darwin-amd64' | 'linux-arm64' | 'linux-amd64'
export const FRPC_PLATFORMS: readonly FrpcPlatform[] = [
'darwin-arm64',
'darwin-amd64',
'linux-arm64',
'linux-amd64',
]
/** A pinned frpc release artifact for one platform. `sha256` is lowercase hex over the artifact. */
export interface FrpcReleaseRef {
readonly version: string
readonly url: string
readonly sha256: string
}
const FRPC_VERSION = '0.61.1'
const RELEASE_BASE = `https://github.com/fatedier/frp/releases/download/v${FRPC_VERSION}`
/**
* The pinned release map. URLs point at the real frp v0.61.1 assets and the `sha256` fields are the
* REAL published digests from `frp_sha256_checksums.txt` (verified against the downloaded
* darwin_arm64 archive on 2026-07-09). To bump frp: update `FRPC_VERSION` and paste the new digests
* from that release's checksum file. Tests inject their own release map.
*/
export const FRPC_RELEASES: Readonly<Record<FrpcPlatform, FrpcReleaseRef>> = {
'darwin-arm64': {
version: FRPC_VERSION,
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_darwin_arm64.tar.gz`,
sha256: '3e65f13a17a284bd6013e6bb6856bc2720074cea6094cc446c1f4c3932154c2d',
},
'darwin-amd64': {
version: FRPC_VERSION,
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_darwin_amd64.tar.gz`,
sha256: '403a0ee5e92f083a863d984b7af1e9d70ba2aaa28e87f42f1fe085adf76b8491',
},
'linux-arm64': {
version: FRPC_VERSION,
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_linux_arm64.tar.gz`,
sha256: 'af6366f2b43920ebfe6235dba6060770399ed1fb18601e5818552bd46a7621f8',
},
'linux-amd64': {
version: FRPC_VERSION,
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_linux_amd64.tar.gz`,
sha256: 'bff260b68ca7b1461182a46c4f34e9709ba32764eed30a15dd94ac97f50a2c40',
},
}
/** Node `os.arch()` values mapped to frp's arch tokens. */
const ARCH_MAP: Readonly<Record<string, 'arm64' | 'amd64'>> = {
arm64: 'arm64',
x64: 'amd64',
}
const SUPPORTED_OS: ReadonlySet<string> = new Set(['darwin', 'linux'])
const BIN_NAME = 'frpc'
const TMP_NAME = 'frpc.download.tmp'
const EXEC_MODE = 0o755
const DIR_MODE = 0o700
/** Map `os.platform()` + `os.arch()` to a supported `FrpcPlatform`, or `null` if unsupported. */
export function detectFrpcPlatform(platform: string, arch: string): FrpcPlatform | null {
const mappedArch = ARCH_MAP[arch]
if (!SUPPORTED_OS.has(platform) || mappedArch === undefined) return null
const key = `${platform}-${mappedArch}` as FrpcPlatform
return FRPC_PLATFORMS.includes(key) ? key : null
}
/** Injectable network fetch: returns the artifact bytes for `url`. */
export type FrpcFetch = (url: string) => Promise<Uint8Array>
/** Injectable filesystem seam (all async, mirrors `node:fs/promises`). */
export interface FrpcFsDeps {
mkdir(dir: string): Promise<void>
writeFile(path: string, data: Uint8Array): Promise<void>
rename(from: string, to: string): Promise<void>
chmod(path: string, mode: number): Promise<void>
rm(path: string): Promise<void>
}
export interface ProvisionFrpcDeps {
readonly fetch: FrpcFetch
readonly fs: FrpcFsDeps
/** Override the digest function (default: node:crypto SHA-256, lowercase hex). */
readonly computeSha256?: (data: Uint8Array) => string
}
export interface ProvisionFrpcOptions {
/** `os.platform()` (e.g. `'darwin'`, `'linux'`). */
readonly platform: string
/** `os.arch()` (e.g. `'arm64'`, `'x64'`). */
readonly arch: string
/** Directory the verified `frpc` binary is placed into. */
readonly binDir: string
/** Release map to select from; defaults to the pinned `FRPC_RELEASES`. */
readonly releases?: Readonly<Record<FrpcPlatform, FrpcReleaseRef>>
}
export interface ProvisionResult {
readonly binPath: string
readonly version: string
readonly platform: FrpcPlatform
}
/** A verify-download failure (unsupported platform, fetch error, or integrity mismatch). */
export class FrpcProvisionError extends Error {
constructor(message: string) {
super(message)
this.name = 'FrpcProvisionError'
}
}
function defaultSha256(data: Uint8Array): string {
return createHash('sha256').update(data).digest('hex')
}
/** Default HTTPS fetch — enforces `https:` (never `-k`, never plain http) and a 2xx status. */
async function defaultFetch(url: string): Promise<Uint8Array> {
let parsed: URL
try {
parsed = new URL(url)
} catch {
throw new FrpcProvisionError(`invalid frpc download URL: ${url}`)
}
if (parsed.protocol !== 'https:') {
throw new FrpcProvisionError(`frpc download refuses non-https URL: ${url}`)
}
let res: Response
try {
res = await fetch(url)
} catch (err: unknown) {
// Surface transport failures (DNS, refused, TLS) through the SAME typed error as every other
// failure path, so callers (e.g. the autoupdate rollback path) can pattern-match uniformly.
throw new FrpcProvisionError(
`frpc download failed: ${err instanceof Error ? err.message : 'network error'} for ${url}`,
)
}
if (!res.ok) {
throw new FrpcProvisionError(`frpc download failed: HTTP ${res.status} for ${url}`)
}
return new Uint8Array(await res.arrayBuffer())
}
const defaultFsDeps: FrpcFsDeps = {
mkdir: async (dir) => {
await mkdir(dir, { recursive: true, mode: DIR_MODE })
},
writeFile: async (path, data) => {
await writeFile(path, data, { mode: 0o600 })
},
rename: async (from, to) => {
await rename(from, to)
},
chmod: async (path, mode) => {
await chmod(path, mode)
},
rm: async (path) => {
await rm(path, { force: true })
},
}
function defaultDeps(): ProvisionFrpcDeps {
return { fetch: defaultFetch, fs: defaultFsDeps, computeSha256: defaultSha256 }
}
/**
* Download + verify + extract + place the pinned frpc binary for the current platform.
*
* Order (verify-before-extract-before-exec): detect platform -> select pin -> fetch archive ->
* write the unverified archive to a per-invocation temp -> SHA-256 verify against the pin. On
* mismatch: remove the temp and throw (no extraction, nothing placed). On match: gunzip+untar to
* pull ONLY the inner `frpc` (path-traversal-safe), overwrite the temp with that verified binary,
* chmod exec, and atomic-rename into place. Any extraction failure removes the temp and throws.
* Nothing is ever placed or made executable before the digest matches the pin.
*/
export async function provisionFrpc(
opts: ProvisionFrpcOptions,
deps: ProvisionFrpcDeps = defaultDeps(),
): Promise<ProvisionResult> {
const platform = detectFrpcPlatform(opts.platform, opts.arch)
if (platform === null) {
throw new FrpcProvisionError(
`unsupported platform for frpc: ${opts.platform}/${opts.arch} (supported: ${FRPC_PLATFORMS.join(', ')})`,
)
}
const releases = opts.releases ?? FRPC_RELEASES
const release = releases[platform]
if (release === undefined) {
throw new FrpcProvisionError(`no pinned frpc release for platform ${platform}`)
}
const computeSha256 = deps.computeSha256 ?? defaultSha256
// Per-invocation-unique temp path: two concurrent provisions (e.g. overlapping autoupdate polls)
// must never write/rename through the same temp file (TOCTOU). Same path is used for write+rm+rename.
const tmpPath = join(opts.binDir, `${TMP_NAME}.${process.pid}.${randomBytes(6).toString('hex')}`)
const binPath = join(opts.binDir, BIN_NAME)
const bytes = await deps.fetch(release.url)
await deps.fs.mkdir(opts.binDir)
await deps.fs.writeFile(tmpPath, bytes)
const digest = computeSha256(bytes).toLowerCase()
const expected = release.sha256.toLowerCase()
if (digest !== expected) {
await deps.fs.rm(tmpPath)
throw new FrpcProvisionError(
`frpc SHA-256 mismatch for ${platform}: expected ${expected}, got ${digest} — nothing placed`,
)
}
// Verified — extract ONLY the inner `frpc` from the archive. The extractor selects by basename and
// rejects any unsafe entry name, so nothing derived from the archive can escape binDir. On any
// extraction failure the (still-archive) temp is removed and nothing is placed.
let frpcBytes: Uint8Array
try {
frpcBytes = extractFrpcBinary(bytes)
} catch (err: unknown) {
await deps.fs.rm(tmpPath)
const detail = err instanceof Error ? err.message : 'unknown error'
throw new FrpcProvisionError(
`frpc extraction failed for ${platform}: ${detail} — nothing placed`,
)
}
// Overwrite the temp with the verified extracted binary, grant exec, then promote atomically.
await deps.fs.writeFile(tmpPath, frpcBytes)
await deps.fs.chmod(tmpPath, EXEC_MODE)
await deps.fs.rename(tmpPath, binPath)
return { binPath, version: release.version, platform }
}

View File

@@ -0,0 +1,159 @@
/**
* Minimal, path-traversal-hardened tar extractor for the frp release archive — TASK B3 extraction
* step (PLAN_TUNNEL_AUTOMATION §3.2 / §5 supply-chain).
*
* frp's GitHub releases ship a `.tar.gz` whose payload is `frp_<ver>_<os>_<arch>/{frpc,frps,...}` —
* the host cannot exec a `.tar.gz`, so after `frpcBinary.ts` has VERIFIED the archive's SHA-256
* against its pin it hands the bytes here to pull out ONLY the inner `frpc`.
*
* SECURITY (§5): this does NOT reconstruct the archive's directory tree on disk. The caller writes
* the returned bytes to a FIXED destination it owns; entries are selected purely by matching the
* (sanitized) tar name's BASENAME. Any candidate entry whose name is absolute, contains a NUL, or
* has a `..` path segment is REJECTED (never silently skipped, so a `../frpc` decoy cannot be used
* to derive a path). A malicious tarball can therefore never cause a write outside the caller's dir.
*
* Format scope: real frp archives are plain USTAR (magic `ustar `) with 100-byte names (no PAX/GNU
* long-name/sparse entries) — verified against frp v0.61.1. The parser reads only what USTAR needs:
* name (0..100), size octal (124..136), typeflag (156); regular files are typeflag `0` or legacy NUL.
*/
import { gunzipSync } from 'node:zlib'
const TAR_BLOCK = 512
const NAME_OFFSET = 0
const NAME_LEN = 100
const SIZE_OFFSET = 124
const SIZE_LEN = 12
const TYPEFLAG_OFFSET = 156
// USTAR regular-file type flags: '0' (0x30) and the legacy NUL (0x00). Everything else (dir '5',
// symlink '2', PAX 'x'/'g', GNU 'L', …) is not a plain file and is skipped when matching.
const TYPE_REGULAR = 0x30
const TYPE_LEGACY = 0x00
// Hard ceiling on a single extracted entry. frp's `frpc` is ~14 MB; 256 MB rejects a corrupt/hostile
// size field before it can drive a huge allocation or an out-of-bounds read.
const MAX_ENTRY_BYTES = 256 * 1024 * 1024
const FRPC_BASENAME = 'frpc'
/** A tar/gzip extraction failure (corrupt gzip, malformed/truncated tar, unsafe or missing entry). */
export class TarExtractError extends Error {
constructor(message: string) {
super(message)
this.name = 'TarExtractError'
}
}
/**
* True if `name` could escape a destination directory: contains a NUL, is an absolute path, or has
* any `..` path segment. Both `/` and `\` are treated as separators (defense in depth).
*/
function isUnsafeEntryName(name: string): boolean {
if (name.length === 0) return true
if (name.includes('\0')) return true
if (name.startsWith('/') || name.startsWith('\\')) return true
return name.split(/[/\\]/).some((segment) => segment === '..')
}
/** Last `/`- or `\`-separated segment of a tar entry name. */
function basename(name: string): string {
const parts = name.split(/[/\\]/)
return parts[parts.length - 1] ?? name
}
/** Decode the NUL-terminated USTAR name field of the header block at `off`. */
function readName(tar: Uint8Array, off: number): string {
const raw = tar.subarray(off + NAME_OFFSET, off + NAME_OFFSET + NAME_LEN)
const nul = raw.indexOf(0)
return new TextDecoder().decode(raw.subarray(0, nul < 0 ? NAME_LEN : nul))
}
/**
* Read a NUL/space-terminated octal numeric field. Leading spaces are tolerated (GNU pads them);
* any non-octal digit yields `NaN` so the caller can reject a corrupt header.
*/
function readOctal(tar: Uint8Array, start: number, len: number): number {
let value = 0
let seenDigit = false
for (let i = start; i < start + len; i++) {
const c = tar[i]
if (c === undefined || c === 0x00 || c === 0x20) {
if (seenDigit) break
continue
}
if (c < 0x30 || c > 0x37) return Number.NaN
value = value * 8 + (c - 0x30)
seenDigit = true
}
return seenDigit ? value : 0
}
/** True if the 512-byte header block at `off` is entirely zero (the end-of-archive marker). */
function isZeroBlock(tar: Uint8Array, off: number): boolean {
for (let i = off; i < off + TAR_BLOCK; i++) {
if (tar[i] !== 0) return false
}
return true
}
/**
* Return the bytes of the FIRST regular-file entry whose safe basename equals `targetBasename`.
*
* Selection is by basename only — the archive's directory structure is never mapped to a path. A
* candidate whose name is unsafe (absolute / NUL / `..`) throws rather than being used. Missing
* entry, a truncated/corrupt tar, or an over-size entry all throw `TarExtractError` (place nothing).
*/
export function extractTarFileByBasename(tar: Uint8Array, targetBasename: string): Uint8Array {
let off = 0
while (off + TAR_BLOCK <= tar.length) {
if (isZeroBlock(tar, off)) break // end-of-archive
const size = readOctal(tar, off + SIZE_OFFSET, SIZE_LEN)
if (!Number.isInteger(size) || size < 0) {
throw new TarExtractError('corrupt tar: invalid entry size field')
}
if (size > MAX_ENTRY_BYTES) {
throw new TarExtractError(`tar entry exceeds max size (${size} > ${MAX_ENTRY_BYTES})`)
}
const contentStart = off + TAR_BLOCK
const contentEnd = contentStart + size
if (contentEnd > tar.length) {
throw new TarExtractError('corrupt tar: entry content is truncated')
}
const typeflag = tar[off + TYPEFLAG_OFFSET]
const isRegular = typeflag === TYPE_REGULAR || typeflag === TYPE_LEGACY
if (isRegular) {
const name = readName(tar, off)
if (basename(name) === targetBasename) {
if (isUnsafeEntryName(name)) {
throw new TarExtractError(`refusing unsafe tar entry name: ${JSON.stringify(name)}`)
}
return tar.slice(contentStart, contentEnd) // copy — never a view into the archive buffer
}
}
// Advance past this entry's content, padded up to the next 512-byte boundary.
off = contentEnd + ((TAR_BLOCK - (size % TAR_BLOCK)) % TAR_BLOCK)
}
throw new TarExtractError(`no "${targetBasename}" file entry found in tar archive`)
}
/**
* Gunzip a verified frp `.tar.gz` and return the inner `frpc` binary's bytes. `gunzipSync` is used
* on already-hash-verified input (the pin gate runs first), so there is no attacker-controlled
* decompression-bomb surface here; a corrupt/non-gzip payload throws `TarExtractError`.
*/
export function extractFrpcBinary(archiveGz: Uint8Array): Uint8Array {
return extractTarFileByBasename(gunzip(archiveGz), FRPC_BASENAME)
}
function gunzip(archiveGz: Uint8Array): Uint8Array {
try {
return new Uint8Array(gunzipSync(archiveGz))
} catch (err: unknown) {
const detail = err instanceof Error ? err.message : 'not a gzip stream'
throw new TarExtractError(`corrupt frpc archive: gunzip failed (${detail})`)
}
}

View File

@@ -1,10 +1,20 @@
/**
* Service install dispatcher — PLAN_RELAY_AGENT T17. Detects the platform, writes the unit, and
* loads it. REFUSES to install as root (EXPLORE §4d least privilege). All IO is injected so the
* logic is unit-testable without touching the real system.
* Service install dispatcher — PLAN_RELAY_AGENT T17, re-targeted for the native tunnel
* (PLAN_TUNNEL_AUTOMATION B5). Detects the platform and emits TWO durable units — the base-app and
* the agent — then loads/enables both. REFUSES to install as root (EXPLORE §4d least privilege).
* All IO is injected so the logic is unit-testable without touching the real system.
*
* Two safety controls are load-bearing here:
* - FIX C-host-1 (S-GATE, CRITICAL): the base-app unit MUST bind loopback. A non-loopback
* `BIND_HOST` (e.g. `0.0.0.0`) is REJECTED — the throw happens before any file is written, so a
* rejected install emits nothing. An absent value is normalized to `127.0.0.1`.
* - FIX M-host-2service: base-app env (BIND_HOST/ALLOWED_ORIGINS/PORT/…) is routed to the
* base-app unit ONLY; the agent unit (which supervises frpc) never carries it.
*/
import type { AgentConfig } from '../config/agentConfig.js'
import {
agentLabel,
baseAppLabel,
buildLaunchdPlist,
launchdLoadCommand,
launchdPlistPath,
@@ -12,6 +22,8 @@ import {
type ServiceEnv,
} from './launchd.js'
import {
agentUnitName,
baseAppUnitName,
buildSystemdUnit,
systemdDisableCommand,
systemdEnableCommand,
@@ -19,15 +31,17 @@ import {
type SystemdUnitOptions,
} from './systemd.js'
import { DEFAULT_ORIGIN_ZONE, mergeOrigins, subdomainOrigin } from './originConfig.js'
import { isLoopbackHostLiteral } from '../net/loopbackLiteral.js'
export type ServicePlatform = 'launchd' | 'systemd'
/**
* Per-host packaging inputs threaded to the unit writers (PLAN_NATIVE_TUNNEL S2). All optional so
* existing callers (and relay callers) are unaffected — an empty object writes the historical unit.
* existing callers (and relay callers) are unaffected. `env` is the BASE-APP env (routed to the
* base-app unit only); `baseAppExec` overrides the base-app `ExecStart` argv.
*/
export interface InstallOptions {
/** S0 env injected into the supervised process (BIND_HOST, ALLOWED_ORIGINS, PORT, …). */
/** Base-app env injected into the base-app unit (BIND_HOST, ALLOWED_ORIGINS, PORT, …). */
readonly env?: ServiceEnv
/** systemd `EnvironmentFile=` path (preferred over inline for values best kept off the unit). */
readonly envFile?: string
@@ -35,26 +49,84 @@ export interface InstallOptions {
readonly zone?: string
/** Parent domain (e.g. `yaojia.wang`); with `cfg.subdomain` derives the tunnel ALLOWED_ORIGINS. */
readonly domain?: string
/** Base-app process argv (default `['node','dist/server.js']`). */
readonly baseAppExec?: readonly string[]
}
/** S0 base-app env vars the install CLI bakes into the per-host unit, passed through verbatim. */
const BASE_APP_ENV_KEYS = ['ALLOWED_ORIGINS', 'PORT', 'SHELL_PATH', 'IDLE_TTL', 'USE_TMUX'] as const
/** S0 base-app env vars the install CLI bakes into the base-app unit, passed through verbatim. */
const BASE_APP_ENV_KEYS = [
'ALLOWED_ORIGINS',
'PORT',
'SHELL_PATH',
'IDLE_TTL',
'USE_TMUX',
'SCROLLBACK_BYTES',
'MAX_PAYLOAD_BYTES',
] as const
/**
* Tunnel hosts MUST bind loopback. At the relay the device-cert mTLS is the ONLY auth gate, so a
* default `0.0.0.0` bind (`src/config.ts`) would serve an unauth'd shell on the LAN, bypassing mTLS
* entirely (PLAN_NATIVE_TUNNEL S0/R2). `buildInstallOptions` therefore defaults `BIND_HOST` here.
* entirely (PLAN_NATIVE_TUNNEL S0/R2, FIX C-host-1). This is the normalized loopback default.
*/
export const TUNNEL_DEFAULT_BIND_HOST = '127.0.0.1'
/** Native-tunnel origin zone → `https://<subdomain>.terminal.<domain>`; overridable via TUNNEL_ZONE. */
export const TUNNEL_ORIGIN_ZONE = 'terminal'
/** Native-tunnel origin zone label; native installs MUST use this (FIX L-host-zone). */
export const NATIVE_ORIGIN_ZONE = 'terminal'
/** Base-app process argv when the caller does not override it. */
const DEFAULT_BASE_APP_EXEC: readonly string[] = ['node', 'dist/server.js']
/** A base-app BIND_HOST that is not loopback — the S-GATE fail-closed error (FIX C-host-1). */
export class BindHostError extends Error {
constructor(value: string) {
super(
`refusing to install: BIND_HOST="${value}" is not loopback. A tunnel host MUST bind ` +
'127.0.0.1/::1/localhost — the device-cert mTLS at the relay is the only auth gate, so a ' +
'0.0.0.0 (or LAN-IP) bind would serve an unauth\'d shell on the LAN. [FIX C-host-1 S-GATE]',
)
this.name = 'BindHostError'
}
}
/**
* True iff `value` is a loopback bind address (a well-formed 127.0.0.0/8 IPv4 literal, ::1, or
* localhost). Delegates to the shared strict check so a suffixed hostname such as
* `127.0.0.1.attacker.example.com` — which Node would DNS-resolve before bind() — is REJECTED,
* not treated as loopback (FIX C-host-1 S-GATE).
*/
function isLoopbackBindHost(value: string): boolean {
return isLoopbackHostLiteral(value)
}
/**
* S-GATE (FIX C-host-1): normalize an absent/empty BIND_HOST to loopback; REJECT any non-loopback
* value (throws `BindHostError`). The emitted base-app unit can therefore never bind `0.0.0.0`.
*/
export function normalizeBindHost(value: string | undefined): string {
if (value === undefined || value.length === 0) return TUNNEL_DEFAULT_BIND_HOST
if (!isLoopbackBindHost(value)) throw new BindHostError(value)
return value
}
/** Assert a native-tunnel install uses the `terminal` zone (FIX L-host-zone). Throws otherwise. */
export function assertNativeZone(zone: string | undefined): void {
if (zone !== NATIVE_ORIGIN_ZONE) {
throw new Error(
`native tunnel install requires zone="${NATIVE_ORIGIN_ZONE}" (got "${zone ?? '(default term)'}")` +
' — the base-app origin must be https://<sub>.terminal.<domain> [FIX L-host-zone]',
)
}
}
/**
* Resolve the per-host `InstallOptions` from the process environment (PLAN_NATIVE_TUNNEL S0/S2).
* Sources the S0 base-app env — defaulting `BIND_HOST` to loopback so a tunnel install can never
* emit a LAN-exposed unit — plus the tunnel-origin `domain`/`zone` used to derive ALLOWED_ORIGINS.
* Pure/immutable: `env` is a parameter, so this is unit-testable without touching real process state.
* Sources the S0 base-app env — normalizing/gating `BIND_HOST` to loopback (S-GATE: throws on a
* non-loopback value so no install can ever emit a LAN-exposed unit) — plus the tunnel-origin
* `domain`/`zone` used to derive ALLOWED_ORIGINS. Pure/immutable (env is a parameter).
*/
export function buildInstallOptions(env: NodeJS.ProcessEnv): InstallOptions {
const passthrough = Object.fromEntries(
@@ -62,13 +134,16 @@ export function buildInstallOptions(env: NodeJS.ProcessEnv): InstallOptions {
(entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].length > 0,
),
)
const serviceEnv: ServiceEnv = { BIND_HOST: env.BIND_HOST || TUNNEL_DEFAULT_BIND_HOST, ...passthrough }
// S-GATE at env-read time: a non-loopback BIND_HOST fails closed here (before any install).
const serviceEnv: ServiceEnv = { BIND_HOST: normalizeBindHost(env.BIND_HOST), ...passthrough }
const domain = env.TUNNEL_DOMAIN
const envFile = env.AGENT_ENV_FILE
const baseAppEntry = env.BASE_APP_ENTRY
return {
env: serviceEnv,
...(domain ? { domain, zone: env.TUNNEL_ZONE || TUNNEL_ORIGIN_ZONE } : {}),
...(envFile ? { envFile } : {}),
...(baseAppEntry ? { baseAppExec: ['node', baseAppEntry] } : {}),
}
}
@@ -96,18 +171,26 @@ export function detectPlatform(os: NodeJS.Platform): ServicePlatform | null {
}
/**
* Resolve the env map injected into the unit. When a `domain` (+ `cfg.subdomain`) is supplied, the
* tunnel origin `https://<subdomain>.<zone>.<domain>` is merged into ALLOWED_ORIGINS so the base app
* trusts its own tunnel host — never weakening any origin the caller already provided. Immutable.
* Resolve the BASE-APP env injected into the base-app unit. Runs the S-GATE on `BIND_HOST` (throws
* `BindHostError` on a non-loopback value, before any write) and, when a `domain` (+ `cfg.subdomain`)
* is supplied, merges the tunnel origin `https://<subdomain>.<zone>.<domain>` into ALLOWED_ORIGINS —
* never weakening an origin the caller already provided. Immutable.
*/
function resolveEnv(cfg: AgentConfig, options: InstallOptions): ServiceEnv {
function resolveBaseAppEnv(cfg: AgentConfig, options: InstallOptions): ServiceEnv {
const base = options.env ?? {}
if (!options.domain || !cfg.subdomain) return base
const bindHost = normalizeBindHost(base.BIND_HOST) // S-GATE — throws on non-loopback
const withBind: ServiceEnv = { ...base, BIND_HOST: bindHost }
if (!options.domain || !cfg.subdomain) return withBind
const origin = subdomainOrigin(cfg.subdomain, options.domain, options.zone ?? DEFAULT_ORIGIN_ZONE)
return { ...base, ALLOWED_ORIGINS: mergeOrigins(base.ALLOWED_ORIGINS, origin) }
return { ...withBind, ALLOWED_ORIGINS: mergeOrigins(withBind.ALLOWED_ORIGINS, origin) }
}
/** Write + load the service unit for `platform`. Throws RootRefusedError if running as root. */
/**
* Write + load BOTH service units for `platform`: the base-app (`node dist/server.js` + base-app
* env) and the agent (`<bin> run`, supervises frpc — no base-app env). Throws RootRefusedError if
* running as root, or BindHostError (S-GATE) BEFORE any write if the base-app BIND_HOST is not
* loopback (so a rejected install emits nothing).
*/
export async function installService(
cfg: AgentConfig,
platform: ServicePlatform,
@@ -115,34 +198,57 @@ export async function installService(
options: InstallOptions = {},
): Promise<void> {
if (deps.getuid() === 0) throw new RootRefusedError()
// Resolve (and S-GATE) the base-app env BEFORE any IO — a non-loopback BIND_HOST throws here,
// so nothing is ever written for a rejected install.
const baseAppEnv = resolveBaseAppEnv(cfg, options)
const bin = deps.binPath()
const env = resolveEnv(cfg, options)
const baseAppExec = options.baseAppExec ?? DEFAULT_BASE_APP_EXEC
if (platform === 'launchd') {
const path = launchdPlistPath(deps.homedir())
deps.writeFile(path, buildLaunchdPlist(bin, env))
const baseAppPath = launchdPlistPath(deps.homedir(), baseAppLabel())
deps.writeFile(baseAppPath, buildLaunchdPlist(baseAppExec, baseAppEnv, baseAppLabel()))
const agentPath = launchdPlistPath(deps.homedir(), agentLabel())
deps.writeFile(agentPath, buildLaunchdPlist([bin, 'run'], {}, agentLabel()))
for (const path of [baseAppPath, agentPath]) {
const { cmd, args } = launchdLoadCommand(path)
await deps.runCommand(cmd, args)
}
return
}
const path = systemdUnitPath(deps.homedir())
const systemdOptions: SystemdUnitOptions = options.envFile
? { env, envFile: options.envFile }
: { env }
deps.writeFile(path, buildSystemdUnit(bin, deps.username(), systemdOptions))
const { cmd, args } = systemdEnableCommand()
const baseAppOptions: SystemdUnitOptions = options.envFile
? { env: baseAppEnv, envFile: options.envFile }
: { env: baseAppEnv }
const baseAppPath = systemdUnitPath(deps.homedir(), baseAppUnitName())
deps.writeFile(
baseAppPath,
buildSystemdUnit(baseAppExec.join(' '), deps.username(), baseAppOptions, 'web-terminal base app (loopback)'),
)
const agentPath = systemdUnitPath(deps.homedir(), agentUnitName())
deps.writeFile(
agentPath,
buildSystemdUnit(`${bin} run`, deps.username(), {}, 'web-terminal host agent (frpc supervisor)'),
)
for (const unit of [baseAppUnitName(), agentUnitName()]) {
const { cmd, args } = systemdEnableCommand(unit)
await deps.runCommand(cmd, args)
}
}
/** Unload the service unit for `platform`. */
/** Unload/disable BOTH service units for `platform` (agent first, then base-app). */
export async function uninstallService(
platform: ServicePlatform,
deps: Pick<InstallDeps, 'runCommand' | 'homedir'>,
): Promise<void> {
if (platform === 'launchd') {
const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir()))
for (const label of [agentLabel(), baseAppLabel()]) {
const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir(), label))
await deps.runCommand(cmd, args)
}
return
}
const { cmd, args } = systemdDisableCommand()
for (const unit of [agentUnitName(), baseAppUnitName()]) {
const { cmd, args } = systemdDisableCommand(unit)
await deps.runCommand(cmd, args)
}
}

View File

@@ -2,23 +2,38 @@
* macOS launchd plist writer — PLAN_RELAY_AGENT T17. The service runs as the LOGGED-IN USER, not
* root (EXPLORE §4d least privilege); no secrets in the plist (key/cert stay in the keystore).
*
* PLAN_NATIVE_TUNNEL S2: the plist can now inject a caller-supplied per-host env map
* PLAN_NATIVE_TUNNEL S2: the plist can inject a caller-supplied per-host env map
* (BIND_HOST, ALLOWED_ORIGINS, PORT, SHELL_PATH, IDLE_TTL, USE_TMUX, …) as a launchd
* `<key>EnvironmentVariables</key><dict>…</dict>` block. Values are XML-escaped and keys are
* sorted for deterministic, immutable output.
*
* PLAN_TUNNEL_AUTOMATION B5 (FIX M-host-2service): the writer is PARAMETERIZED on `label` +
* `programArguments`, so a native-tunnel install emits TWO distinct plists — the base-app
* (`node dist/server.js`, carrying the base-app env) and the agent (`<bin> run`, which supervises
* frpc). Base-app env is routed to the base-app plist ONLY by the caller (`install.ts`).
*/
/** Shared env-map shape for the durable-service writers (launchd + systemd). Immutable. */
export type ServiceEnv = Readonly<Record<string, string>>
const LABEL = 'com.web-terminal.agent'
/** The native-tunnel agent unit (supervises frpc). */
const AGENT_LABEL = 'com.web-terminal.agent'
/** The base-app unit (`node dist/server.js`, loopback-bound). */
const BASE_APP_LABEL = 'com.web-terminal.base-app'
export function agentLabel(): string {
return AGENT_LABEL
}
export function baseAppLabel(): string {
return BASE_APP_LABEL
}
/** Back-compat alias for the agent label. */
export function launchdLabel(): string {
return LABEL
return AGENT_LABEL
}
export function launchdPlistPath(homedir: string): string {
return `${homedir}/Library/LaunchAgents/${LABEL}.plist`
export function launchdPlistPath(homedir: string, label: string = AGENT_LABEL): string {
return `${homedir}/Library/LaunchAgents/${label}.plist`
}
/** Escape the five XML-significant characters so env keys/values/paths are plist-safe. */
@@ -44,24 +59,34 @@ function environmentVariablesBlock(env: ServiceEnv): readonly string[] {
return lines
}
/** Build the `<key>ProgramArguments</key><array>…</array>` lines. */
function programArgumentsBlock(programArguments: readonly string[]): readonly string[] {
const lines = [' <key>ProgramArguments</key>', ' <array>']
for (const arg of programArguments) {
lines.push(` <string>${escapeXml(arg)}</string>`)
}
lines.push(' </array>')
return lines
}
/**
* Build the plist. ExecStart = `<bin> run`; RunAtLoad + KeepAlive (restart on failure). When `env`
* is non-empty, a launchd `EnvironmentVariables` dict is injected so the supervised process
* receives the per-host tunnel config. Pure/immutable — returns a fresh string.
* Build a plist for `label` running `programArguments` (e.g. `[bin, 'run']` or `[node, serverJs]`);
* RunAtLoad + KeepAlive (restart on failure). When `env` is non-empty, a launchd
* `EnvironmentVariables` dict is injected. Pure/immutable — returns a fresh string.
*/
export function buildLaunchdPlist(binPath: string, env: ServiceEnv = {}): string {
export function buildLaunchdPlist(
programArguments: readonly string[],
env: ServiceEnv = {},
label: string = AGENT_LABEL,
): string {
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
'<plist version="1.0">',
'<dict>',
' <key>Label</key>',
` <string>${escapeXml(LABEL)}</string>`,
' <key>ProgramArguments</key>',
' <array>',
` <string>${escapeXml(binPath)}</string>`,
' <string>run</string>',
' </array>',
` <string>${escapeXml(label)}</string>`,
...programArgumentsBlock(programArguments),
' <key>RunAtLoad</key>',
' <true/>',
' <key>KeepAlive</key>',

View File

@@ -2,14 +2,22 @@
* Linux systemd unit writer — PLAN_RELAY_AGENT T17. Runs as the LOGGED-IN USER (never root,
* EXPLORE §4d), restart-on-failure; no secrets in the unit (key/cert stay in the keystore).
*
* PLAN_NATIVE_TUNNEL S2: the unit can now inject the per-host tunnel env via an
* `EnvironmentFile=` line (preferred — keeps values out of the world-readable unit) and/or
* inline `Environment=` lines from a caller-supplied env map. Inline `Environment=` is emitted
* after `EnvironmentFile=` so an explicit value overrides the file on conflict.
* PLAN_NATIVE_TUNNEL S2: the unit can inject the per-host tunnel env via an `EnvironmentFile=`
* line (preferred — keeps values out of the world-readable unit) and/or inline `Environment=`
* lines from a caller-supplied env map. Inline `Environment=` is emitted after `EnvironmentFile=`
* so an explicit value overrides the file on conflict.
*
* PLAN_TUNNEL_AUTOMATION B5 (FIX M-host-2service): the writer is PARAMETERIZED on the full
* `ExecStart` command + `Description`, so a native-tunnel install emits TWO distinct units — the
* base-app (`node dist/server.js`, carrying the base-app env) and the agent (`<bin> run`, which
* supervises frpc). Base-app env is routed to the base-app unit ONLY by the caller (`install.ts`).
*/
import type { ServiceEnv } from './launchd.js'
const UNIT_NAME = 'web-terminal-agent.service'
/** The native-tunnel agent unit (supervises frpc). */
const AGENT_UNIT = 'web-terminal-agent.service'
/** The base-app unit (`node dist/server.js`, loopback-bound). */
const BASE_APP_UNIT = 'web-terminal-base-app.service'
/** DEL (0x7F) and everything below the printable ASCII range are rejected in env values. */
const FIRST_PRINTABLE_ASCII = 0x20
@@ -22,18 +30,25 @@ export interface SystemdUnitOptions {
readonly envFile?: string
}
export function agentUnitName(): string {
return AGENT_UNIT
}
export function baseAppUnitName(): string {
return BASE_APP_UNIT
}
/** Back-compat alias for the agent unit name. */
export function systemdUnitName(): string {
return UNIT_NAME
return AGENT_UNIT
}
export function systemdUnitPath(homedir: string): string {
return `${homedir}/.config/systemd/user/${UNIT_NAME}`
export function systemdUnitPath(homedir: string, unitName: string = AGENT_UNIT): string {
return `${homedir}/.config/systemd/user/${unitName}`
}
/**
* True if `value` contains any control character (C0 range below 0x20, or DEL 0x7F). A raw
* newline/CR would terminate the single `Environment=` line and let the remainder inject arbitrary
* directives into the `[Service]` section — so such values are rejected rather than escaped.
* newline/CR would terminate the current line and let the remainder inject arbitrary directives into
* the `[Service]` section — so such values are rejected rather than escaped.
*/
function hasControlChar(value: string): boolean {
for (const char of value) {
@@ -44,14 +59,25 @@ function hasControlChar(value: string): boolean {
return false
}
/** Quote a systemd `Environment=` value: reject control chars, then double-quote escaping `\` and `"`. */
function quoteEnvAssignment(key: string, value: string): string {
/**
* Reject ANY field interpolated into the unit that carries a control character. EVERY value written
* into the unit (ExecStart, User, Description, EnvironmentFile path, and each Environment key+value)
* flows through this one guard, so no field can smuggle a newline that starts a new `[Service]`
* directive. Fail loud rather than escape — these fields are never legitimately multi-line.
*/
function assertNoControlChar(fieldName: string, value: string): void {
if (hasControlChar(value)) {
throw new Error(
`refusing to write systemd Environment for '${key}': value contains a control character ` +
`refusing to write systemd unit: ${fieldName} contains a control character ` +
'(newline/CR/etc.) that could corrupt the [Service] section',
)
}
}
/** Quote a systemd `Environment=` value: reject control chars (key AND value), then escape `\` and `"`. */
function quoteEnvAssignment(key: string, value: string): string {
assertNoControlChar(`Environment key '${key}'`, key)
assertNoControlChar(`Environment value for '${key}'`, value)
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
return `"${key}=${escaped}"`
}
@@ -59,7 +85,10 @@ function quoteEnvAssignment(key: string, value: string): string {
/** Build the `EnvironmentFile=` / `Environment=` lines (empty array when neither is supplied). */
function environmentLines(options: SystemdUnitOptions): readonly string[] {
const lines: string[] = []
if (options.envFile) lines.push(`EnvironmentFile=${options.envFile}`)
if (options.envFile) {
assertNoControlChar('EnvironmentFile path', options.envFile)
lines.push(`EnvironmentFile=${options.envFile}`)
}
const entries = Object.entries(options.env ?? {}).sort(([a], [b]) => a.localeCompare(b))
for (const [key, value] of entries) {
lines.push(`Environment=${quoteEnvAssignment(key, value)}`)
@@ -68,23 +97,29 @@ function environmentLines(options: SystemdUnitOptions): readonly string[] {
}
/**
* Build the unit. ExecStart = `<bin> run`; Restart=on-failure; User=<user> (never root). When
* `options.env`/`options.envFile` are supplied, the per-host tunnel env is injected into the
* `[Service]` section. Pure/immutable — returns a fresh string.
* Build a unit whose `ExecStart` is `execStart` (a full command line, e.g. `<bin> run` or
* `node dist/server.js`); Restart=on-failure; User=<user> (never root). When `options.env`/
* `options.envFile` are supplied, the per-host tunnel env is injected. Pure/immutable.
*/
export function buildSystemdUnit(
binPath: string,
execStart: string,
user: string,
options: SystemdUnitOptions = {},
description: string = 'web-terminal host agent',
): string {
// Guard every non-env field the same way env values are guarded — a newline in ExecStart/User/
// Description would otherwise inject a `[Service]` directive on the next line.
assertNoControlChar('ExecStart', execStart)
assertNoControlChar('User', user)
assertNoControlChar('Description', description)
return [
'[Unit]',
'Description=web-terminal host agent (rendezvous relay)',
`Description=${description}`,
'After=network-online.target',
'',
'[Service]',
'Type=simple',
`ExecStart=${binPath} run`,
`ExecStart=${execStart}`,
'Restart=on-failure',
'RestartSec=1',
`User=${user}`,
@@ -96,9 +131,13 @@ export function buildSystemdUnit(
].join('\n')
}
export function systemdEnableCommand(): { cmd: string; args: readonly string[] } {
return { cmd: 'systemctl', args: ['--user', 'enable', '--now', UNIT_NAME] }
export function systemdEnableCommand(
unitName: string = AGENT_UNIT,
): { cmd: string; args: readonly string[] } {
return { cmd: 'systemctl', args: ['--user', 'enable', '--now', unitName] }
}
export function systemdDisableCommand(): { cmd: string; args: readonly string[] } {
return { cmd: 'systemctl', args: ['--user', 'disable', '--now', UNIT_NAME] }
export function systemdDisableCommand(
unitName: string = AGENT_UNIT,
): { cmd: string; args: readonly string[] } {
return { cmd: 'systemctl', args: ['--user', 'disable', '--now', unitName] }
}

View File

@@ -0,0 +1,200 @@
/**
* Native-tunnel frpc supervisor — TASK B4/H4 (PLAN_TUNNEL_AUTOMATION §3.2).
*
* Spawns the pinned `frpc` child with the written `frpc.toml` and keeps it running: on every exit
* it restarts the child after an exponential backoff (REUSES `transport/backoff.ts` — the same
* 1s/2s/4s…cap-30s policy as the relay reconnect). A child that ran longer than a stability window
* resets the backoff, so a healthy long-lived tunnel that finally dies restarts fast, while a
* crash-loop stays capped at 30s. All IO — spawn, sleep, clock — is injected so the loop is
* fully offline-testable (no real frpc / no real timers). No `console.log` (INV9 redacting logger).
*
* SCOPE (deferral #11): the real `frpc` binary run is pending B3 tar.gz extraction; the default
* `spawn` shells out to the provisioned binary, but tests inject a fake child so nothing executes.
*
* LOG CAPTURE (B4/H4 wiring fix): when a `logFile` is supplied, the default spawn tees the frpc
* child's stdout+stderr into that file (truncated per (re)spawn) so the health probe's log scanner
* (`readFrpcLog` → `frpcProxyStarted`) has real content to match "start proxy success" against.
* Truncate-per-spawn keeps the file bounded to the CURRENT child and free of a stale success line
* from a dead one — a freshly connected frpc always re-logs the success line.
*/
import { spawn as nodeSpawn } from 'node:child_process'
import { createWriteStream, mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import { createBackoff, type BackoffPolicy, type Sleep } from './backoff.js'
import { createLogger, type Logger } from '../log/logger.js'
/** A child process seam the supervisor drives (a fake child in tests; a real frpc in prod). */
export interface FrpcChild {
/** Register a one-shot exit handler (`null` code ⇒ killed by signal or spawn error). */
onExit(cb: (code: number | null) => void): void
/** Whether the child is still running (feeds the health probe's `isFrpcAlive`). */
isAlive(): boolean
/** Request termination. */
kill(): void
}
/** Spawn a frpc child running `frpc -c <tomlPath>`. */
export type SpawnFrpc = (binPath: string, tomlPath: string) => FrpcChild
/** Injectable seams for the supervisor; unset fields default to real spawn/sleep/clock/logger. */
export interface FrpSuperviseDeps {
spawn: SpawnFrpc
backoff: BackoffPolicy
sleep: Sleep
logger: Logger
now: () => number
}
/**
* Supervisor overrides: any `FrpSuperviseDeps` seam plus an optional `logFile`. When `logFile` is
* set and no explicit `spawn` is given, the default spawn tees frpc's stdout/stderr into that file
* so the health probe can scan it. An explicit `spawn` (tests) always wins over `logFile`.
*/
export interface FrpSuperviseOptions extends Partial<FrpSuperviseDeps> {
readonly logFile?: string
}
/** Handle returned by `superviseFrpc`: stop the loop, or await its terminal exit code. */
export interface FrpSuperviseHandle {
/** Request graceful shutdown (kills the child); resolves once the loop has fully stopped. */
stop(): Promise<void>
/** Resolves with an exit code (always 0 — a stopped supervisor is a clean shutdown). */
readonly done: Promise<number>
/** True while a frpc child is currently running (for the health probe). */
isChildAlive(): boolean
}
/** A frpc run lasting at least this long is "stable" ⇒ reset the restart backoff. */
export const STABLE_RUN_MS = 60_000
const realSleep: Sleep = (ms) => new Promise<void>((r) => setTimeout(r, ms))
/**
* Default spawn (no log capture): launch the provisioned frpc binary, discarding its stdout/stderr.
* `stdio: 'ignore'` (not `'pipe'`) is deliberate — an unconsumed pipe fills its OS buffer (~64KB) and
* then BLOCKS the child. Log capture is opt-in via `createFileLoggingSpawn` (see `logFile`).
*/
const realSpawn: SpawnFrpc = (binPath, tomlPath) => {
const cp = nodeSpawn(binPath, ['-c', tomlPath], { stdio: ['ignore', 'ignore', 'ignore'] })
let alive = true
return {
onExit(cb: (code: number | null) => void): void {
cp.once('exit', (code) => {
alive = false
cb(code)
})
cp.once('error', () => {
alive = false
cb(null)
})
},
isAlive: () => alive,
kill: () => {
cp.kill()
},
}
}
/**
* Spawn frpc and TEE its stdout+stderr into `logFile` (truncated per spawn) so the health probe's
* `readFrpcLog` scanner has real content. A log-write failure is swallowed — persisting the log for
* the probe must never crash the supervised tunnel.
*/
export function createFileLoggingSpawn(logFile: string): SpawnFrpc {
return (binPath, tomlPath) => {
mkdirSync(dirname(logFile), { recursive: true })
const cp = nodeSpawn(binPath, ['-c', tomlPath], { stdio: ['ignore', 'pipe', 'pipe'] })
// flags:'w' truncates so the file reflects only the current child (no stale success line).
const log = createWriteStream(logFile, { flags: 'w' })
log.on('error', () => {
/* a log-write failure is non-fatal to the tunnel; the probe just sees no success line */
})
cp.stdout?.pipe(log, { end: false })
cp.stderr?.pipe(log, { end: false })
let alive = true
const closeLog = (): void => {
log.end()
}
return {
onExit(cb: (code: number | null) => void): void {
cp.once('exit', (code) => {
alive = false
closeLog()
cb(code)
})
cp.once('error', () => {
alive = false
closeLog()
cb(null)
})
},
isAlive: () => alive,
kill: () => {
cp.kill()
},
}
}
}
function resolveDeps(o?: FrpSuperviseOptions): FrpSuperviseDeps {
const defaultSpawn = o?.logFile !== undefined ? createFileLoggingSpawn(o.logFile) : realSpawn
return {
spawn: o?.spawn ?? defaultSpawn,
backoff: o?.backoff ?? createBackoff({ jitter: true }),
sleep: o?.sleep ?? realSleep,
logger: o?.logger ?? createLogger('info'),
now: o?.now ?? Date.now,
}
}
/**
* Start supervising frpc. Returns immediately with a handle; the restart loop runs in the
* background. `stop()` sets the stop flag, kills the live child, and awaits loop termination.
*/
export function superviseFrpc(
binPath: string,
tomlPath: string,
overrides?: FrpSuperviseOptions,
): FrpSuperviseHandle {
const { spawn, backoff, sleep, logger, now } = resolveDeps(overrides)
let stopped = false
let child: FrpcChild | null = null
/** Spawn one frpc child and resolve when it exits. */
function runOnce(): Promise<number | null> {
return new Promise<number | null>((resolve) => {
const c = spawn(binPath, tomlPath)
child = c
c.onExit((code) => {
child = null
resolve(code)
})
})
}
async function loop(): Promise<number> {
while (!stopped) {
const startedAt = now()
const exitCode = await runOnce()
if (stopped) break
if (now() - startedAt >= STABLE_RUN_MS) backoff.reset()
const delayMs = backoff.nextDelayMs()
logger.log('warn', 'frpc exited — restarting after backoff', { exitCode, delayMs })
await sleep(delayMs)
}
return 0
}
const done = loop()
return {
async stop(): Promise<void> {
stopped = true
child?.kill()
await done
},
done,
isChildAlive: () => child?.isAlive() ?? false,
}
}

View File

@@ -0,0 +1,155 @@
/**
* Native-tunnel frpc.toml writer — TASK B2h (PLAN_TUNNEL_AUTOMATION §3.2 / PLAN_NATIVE_TUNNEL §4).
*
* Emits the frp v0.61 TOML that a host's `frpc` presents to the VPS:
* - dials `serverAddr:443`, SNI-routed by nginx `ssl_preread` to frps :7000;
* - control-channel mTLS (frp-client cert/key + trusted CA) + shared token;
* - one `[[proxies]]` of `type = "http"` exposing the loopback base app as `<sub>.terminal...`.
*
* SUPERSEDES the retired v0.8 `[common]/tls_enable` shape in `frpScaffold.ts` (do not reuse that
* grammar — this is the native-tunnel writer).
*
* ANTI-SSRF (hard invariant): `localIP` MUST be loopback. frpc forwards ONLY to the local base app,
* never an arbitrary target — a non-loopback `localIP` would turn the tunnel into an open proxy.
* All inputs are validated at this boundary (fail-fast, clear messages); no `console.log`.
*/
const DEFAULT_SERVER_ADDR = '8.138.1.192'
const SERVER_PORT = 443
const TLS_SERVER_NAME = 'frp.terminal.yaojia.wang'
const DEFAULT_LOCAL_IP = '127.0.0.1'
const MIN_PORT = 1
const MAX_PORT = 65535
const MAX_LABEL_LEN = 63
const DEL_CHAR_CODE = 0x7f
const FIRST_PRINTABLE_CODE = 0x20
/** Loopback forms accepted for `localIP` (anti-SSRF allowlist). */
const LOOPBACK_IPS: readonly string[] = ['127.0.0.1', '::1', 'localhost']
/** RFC 1035 DNS label: 1-63 chars, alnum, internal hyphens only (no leading/trailing hyphen). */
const LABEL_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
export interface NativeFrpcOptions {
/** Subdomain label -> reachable at `https://<subdomain>.terminal.yaojia.wang`. Label-safe. */
readonly subdomain: string
/** Loopback port of the local base app frpc forwards to (1-65535). */
readonly localPort: number
/** Shared frps auth token (kept out of logs; the file itself is chmod 600 by the caller). */
readonly authToken: string
/** Keystore path to this host's frp-client leaf cert (control-channel mTLS). */
readonly certFile: string
/** Keystore path to the matching private key. */
readonly keyFile: string
/** Keystore path to the CA that signed the frps control cert (server verification). */
readonly trustedCaFile: string
/** VPS address; defaults to the deployed relay `8.138.1.192`. */
readonly serverAddr?: string
/** Local forward target IP; MUST be loopback. Defaults to `127.0.0.1`. */
readonly localIP?: string
}
/** A validation failure at the frpc.toml boundary. */
export class FrpcTomlError extends Error {
constructor(message: string) {
super(message)
this.name = 'FrpcTomlError'
}
}
/** True iff `value` contains an ASCII control character (C0 range or DEL). */
function hasControlChar(value: string): boolean {
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i)
if (code < FIRST_PRINTABLE_CODE || code === DEL_CHAR_CODE) return true
}
return false
}
/** Non-empty, control-char-free path/secret at the boundary. */
function assertPresent(field: string, value: string): void {
if (typeof value !== 'string' || value.length === 0) {
throw new FrpcTomlError(`${field} is required`)
}
if (hasControlChar(value)) {
throw new FrpcTomlError(`${field} contains control characters`)
}
}
/** Escape a value for a TOML basic (double-quoted) string: backslash + quote (Windows paths). */
function tomlBasicString(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
}
function assertLoopback(localIP: string): void {
if (!LOOPBACK_IPS.includes(localIP)) {
throw new FrpcTomlError(
`localIP "${localIP}" is not loopback — frpc must forward only to the local base app (anti-SSRF)`,
)
}
}
function assertSubdomain(subdomain: string): void {
if (typeof subdomain !== 'string' || subdomain.length === 0) {
throw new FrpcTomlError('subdomain is required')
}
if (subdomain.length > MAX_LABEL_LEN || !LABEL_RE.test(subdomain)) {
throw new FrpcTomlError(
`subdomain "${subdomain}" is not a valid DNS label (alnum + internal hyphens, 1-63 chars)`,
)
}
}
function assertLocalPort(localPort: number): void {
if (!Number.isInteger(localPort) || localPort < MIN_PORT || localPort > MAX_PORT) {
throw new FrpcTomlError(
`localPort must be an integer in ${MIN_PORT}-${MAX_PORT}, got ${localPort}`,
)
}
}
/**
* Build a validated frp v0.61 `frpc.toml` for the native mTLS tunnel. Throws `FrpcTomlError` on any
* invalid input (fail-fast). The returned string is the full config file contents.
*/
export function buildNativeFrpcToml(opts: NativeFrpcOptions): string {
assertSubdomain(opts.subdomain)
assertLocalPort(opts.localPort)
assertPresent('authToken', opts.authToken)
assertPresent('certFile', opts.certFile)
assertPresent('keyFile', opts.keyFile)
assertPresent('trustedCaFile', opts.trustedCaFile)
const serverAddr = opts.serverAddr ?? DEFAULT_SERVER_ADDR
assertPresent('serverAddr', serverAddr)
const localIP = opts.localIP ?? DEFAULT_LOCAL_IP
assertLoopback(localIP)
const lines: readonly string[] = [
`serverAddr = "${tomlBasicString(serverAddr)}"`,
`serverPort = ${SERVER_PORT}`,
'',
'auth.method = "token"',
`auth.token = "${tomlBasicString(opts.authToken)}"`,
'',
'# control-channel mTLS: present this host frp-client cert; verify the frps control cert',
'transport.tls.enable = true',
`transport.tls.serverName = "${TLS_SERVER_NAME}"`,
'transport.tls.disableCustomTLSFirstByte = true',
`transport.tls.certFile = "${tomlBasicString(opts.certFile)}"`,
`transport.tls.keyFile = "${tomlBasicString(opts.keyFile)}"`,
`transport.tls.trustedCaFile = "${tomlBasicString(opts.trustedCaFile)}"`,
'',
'loginFailExit = false',
'',
'[[proxies]]',
`name = "${opts.subdomain}"`,
'type = "http"',
`localIP = "${localIP}"`,
`localPort = ${opts.localPort}`,
`subdomain = "${opts.subdomain}"`,
'',
]
return lines.join('\n')
}

View File

@@ -33,10 +33,20 @@ describe('AgentConfig validation', () => {
expect(isLoopbackWsUrl('ws://127.0.0.1:3000')).toBe(true)
expect(isLoopbackWsUrl('ws://localhost:3000')).toBe(true)
expect(isLoopbackWsUrl('ws://127.5.5.5:3000')).toBe(true)
expect(isLoopbackWsUrl('ws://[::1]:3000')).toBe(true)
expect(isLoopbackWsUrl('ws://10.0.0.5:3000')).toBe(false)
expect(isLoopbackWsUrl('wss://127.0.0.1:3000')).toBe(false)
})
it('REGRESSION: rejects a crafted suffixed-hostname target (anti-SSRF bypass)', () => {
// Hostname, not a loopback literal — the outbound dial would DNS-resolve and connect out.
expect(isLoopbackWsUrl('ws://127.0.0.1.attacker.example.com:3000/x')).toBe(false)
expect(isLoopbackWsUrl('ws://127.evil.net:3000')).toBe(false)
expect(() =>
AgentConfigSchema.parse({ ...base, localTargetUrl: 'ws://127.0.0.1.attacker.example.com:3000' }),
).toThrow()
})
it('loadAgentConfig fails fast on a missing relayUrl', () => {
expect(() => loadAgentConfig({} as NodeJS.ProcessEnv, {})).toThrow()
})

View File

@@ -4,6 +4,7 @@ import type { Keystore } from '../src/keys/keystore.js'
import type { AgentIdentity } from '../src/keys/identity.js'
import type { EnrollResult } from 'relay-contracts'
import { CliUsageError, parseArgs, runCli, type CliDeps } from '../src/cli.js'
import type { InstallOptions } from '../src/service/install.js'
const CFG: AgentConfig = {
relayUrl: 'wss://relay/agent',
@@ -14,8 +15,9 @@ const CFG: AgentConfig = {
hostId: 'h-1',
}
function fakeIdentity(): AgentIdentity {
function fakeIdentity(alg: AgentIdentity['alg'] = 'ed25519'): AgentIdentity {
return {
alg,
publicKey: new Uint8Array(32),
enrollFpr: 'fpr',
sign: () => new Uint8Array(64),
@@ -24,10 +26,10 @@ function fakeIdentity(): AgentIdentity {
}
}
function fakeKeystore(enrolled: boolean): Keystore {
function fakeKeystore(enrolled: boolean, alg: AgentIdentity['alg'] = 'ed25519'): Keystore {
return {
saveIdentity: vi.fn(),
loadIdentity: () => (enrolled ? fakeIdentity() : null),
loadIdentity: () => (enrolled ? fakeIdentity(alg) : null),
saveCert: vi.fn(),
loadCert: () => (enrolled ? { certPem: 'C', caChainPem: 'CA' } : null),
saveContentSecret: vi.fn(),
@@ -35,12 +37,19 @@ function fakeKeystore(enrolled: boolean): Keystore {
}
}
const NATIVE_OPTIONS: InstallOptions = {
env: { BIND_HOST: '127.0.0.1', PORT: '3000' },
domain: 'yaojia.wang',
zone: 'terminal',
}
function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps; out: string[] } {
const out: string[] = []
const d: CliDeps = {
loadConfig: () => CFG,
openKeystore: () => fakeKeystore(enrolled),
generateIdentity: fakeIdentity,
generateIdentity: () => fakeIdentity('ed25519'),
generateP256Identity: () => fakeIdentity('p256'),
redeem: async (): Promise<EnrollResult> => ({
hostId: 'h-1',
subdomain: 'host-42',
@@ -48,8 +57,13 @@ function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps;
caChain: 'CA',
hostContentSecret: new Uint8Array([1]),
}),
enrollNative: async () => ({ hostId: 'h-1', subdomain: 'host-42' }),
provisionFrpc: async () => '/opt/frpc',
writeFrpcConfig: vi.fn(),
nativeConfigExists: () => false,
runTunnel: async () => 0,
resolveInstallOptions: () => ({ env: { BIND_HOST: '127.0.0.1' } }),
superviseFrpc: async () => 0,
resolveInstallOptions: () => NATIVE_OPTIONS,
installService: vi.fn(async () => {}),
uninstallService: vi.fn(async () => {}),
print: (l) => out.push(l),
@@ -80,7 +94,7 @@ describe('parseArgs (T5)', () => {
})
})
describe('runCli (T5)', () => {
describe('runCli — legacy relay pair (no --install)', () => {
it('pair happy path calls redeem and prints no secrets', async () => {
const { d, out } = deps()
const code = await runCli(parseArgs(['pair', 'ABCD']), d)
@@ -88,23 +102,97 @@ describe('runCli (T5)', () => {
expect(out.join('\n')).toContain('host-42')
expect(out.join('\n')).not.toContain('PEM')
})
it('pair --install installs the service with the resolved options', async () => {
const options = { env: { BIND_HOST: '127.0.0.1', PORT: '3000' } }
const install = vi.fn(async () => {})
const { d } = deps({ resolveInstallOptions: () => options, installService: install })
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
expect(install).toHaveBeenCalledOnce()
expect(install).toHaveBeenCalledWith(CFG, options)
})
describe('runCli — native pair --install onboard (B5)', () => {
it('wires keygen(P-256)→enroll→provisionFrpc→write toml+env→install both→print URL, in order', async () => {
const calls: string[] = []
const enrolledIds: AgentIdentity[] = []
const generateP256Identity = vi.fn(() => {
calls.push('keygen')
return fakeIdentity('p256')
})
const enrollNative = vi.fn(async (_cfg: AgentConfig, _code: string, id: AgentIdentity) => {
calls.push('enroll')
enrolledIds.push(id)
return { hostId: 'h-1', subdomain: 'host-42' }
})
const provisionFrpc = vi.fn(async () => {
calls.push('provision')
return '/opt/frpc'
})
const writeFrpcConfig = vi.fn(() => {
calls.push('writeConfig')
})
const installService = vi.fn(async () => {
calls.push('install')
})
const { d, out } = deps({
generateP256Identity,
enrollNative,
provisionFrpc,
writeFrpcConfig,
installService,
})
const code = await runCli(parseArgs(['pair', 'ABCD-1234', '--install']), d)
expect(code).toBe(0)
expect(calls).toEqual(['keygen', 'enroll', 'provision', 'writeConfig', 'install'])
// CSR is built from a P-256 identity (FIX H-host-2)
expect(enrolledIds[0]!.alg).toBe('p256')
// enroll seam received the pairing code
expect(enrollNative).toHaveBeenCalledWith(CFG, 'ABCD-1234', expect.anything(), expect.anything())
// both units installed with the resolved options (env routed to base-app by installService)
expect(installService).toHaveBeenCalledWith(CFG, NATIVE_OPTIONS)
// frpc.toml written for the returned subdomain
expect(writeFrpcConfig).toHaveBeenCalledWith(CFG, 'host-42')
// prints the final tunnel URL
expect(out.join('\n')).toContain('https://host-42.terminal.yaojia.wang')
})
it('does NOT use the legacy relay redeem path on --install', async () => {
const redeem = vi.fn()
const { d } = deps({ redeem: redeem as unknown as CliDeps['redeem'] })
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
expect(redeem).not.toHaveBeenCalled()
})
it('saves the freshly generated P-256 identity before enrolling', async () => {
const ks = fakeKeystore(false)
const { d } = deps({ openKeystore: () => ks })
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
expect(ks.saveIdentity).toHaveBeenCalled()
})
it('rejects a native install whose zone is not `terminal` (FIX L-host-zone)', async () => {
const { d } = deps({
resolveInstallOptions: () => ({ env: { BIND_HOST: '127.0.0.1' }, domain: 'yaojia.wang', zone: 'term' }),
})
await expect(runCli(parseArgs(['pair', 'ABCD', '--install']), d)).rejects.toThrow(/terminal/)
})
it('rejects a native install with no TUNNEL_DOMAIN (cannot form the origin)', async () => {
const { d } = deps({ resolveInstallOptions: () => ({ env: { BIND_HOST: '127.0.0.1' } }) })
await expect(runCli(parseArgs(['pair', 'ABCD', '--install']), d)).rejects.toBeInstanceOf(CliUsageError)
})
it('prints no key/cert material during --install (INV9)', async () => {
const { d, out } = deps()
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
const joined = out.join('\n')
expect(joined).not.toContain('PEM')
expect(joined).not.toContain('CA')
})
})
describe('runCli — install / run / status', () => {
it('install threads the resolved InstallOptions into installService (S2 env injection)', async () => {
const options = { env: { BIND_HOST: '127.0.0.1' }, domain: 'yaojia.wang', zone: 'terminal' }
const install = vi.fn(async () => {})
const { d } = deps({ resolveInstallOptions: () => options, installService: install })
const { d } = deps({ resolveInstallOptions: () => NATIVE_OPTIONS, installService: install })
const code = await runCli(parseArgs(['install']), d)
expect(code).toBe(0)
expect(install).toHaveBeenCalledWith(CFG, options)
expect(install).toHaveBeenCalledWith(CFG, NATIVE_OPTIONS)
})
it('run before pairing fails fast', async () => {
@@ -112,6 +200,48 @@ describe('runCli (T5)', () => {
await expect(runCli({ command: 'run', flags: {} }, d)).rejects.toBeInstanceOf(CliUsageError)
})
it('legacy run (Ed25519 identity, no frpc.toml) drives runTunnel — not frpc supervision', async () => {
const runTunnel = vi.fn(async () => 0)
const superviseFrpc = vi.fn(async () => 0)
const { d } = deps(
{ runTunnel, superviseFrpc, nativeConfigExists: () => false },
true, // enrolled with the default Ed25519 identity
)
const code = await runCli({ command: 'run', flags: {} }, d)
expect(code).toBe(0)
expect(runTunnel).toHaveBeenCalledWith(CFG, expect.anything())
expect(superviseFrpc).not.toHaveBeenCalled()
})
it('native run (P-256 identity + frpc.toml) supervises frpc — not the legacy relay', async () => {
const runTunnel = vi.fn(async () => 0)
const superviseFrpc = vi.fn(async () => 0)
const { d } = deps({
openKeystore: () => fakeKeystore(true, 'p256'),
nativeConfigExists: () => true,
runTunnel,
superviseFrpc,
})
const code = await runCli({ command: 'run', flags: {} }, d)
expect(code).toBe(0)
expect(superviseFrpc).toHaveBeenCalledWith(CFG, expect.anything())
expect(runTunnel).not.toHaveBeenCalled()
})
it('native identity but missing frpc.toml falls back to the legacy relay path', async () => {
const runTunnel = vi.fn(async () => 0)
const superviseFrpc = vi.fn(async () => 0)
const { d } = deps({
openKeystore: () => fakeKeystore(true, 'p256'),
nativeConfigExists: () => false,
runTunnel,
superviseFrpc,
})
await runCli({ command: 'run', flags: {} }, d)
expect(runTunnel).toHaveBeenCalled()
expect(superviseFrpc).not.toHaveBeenCalled()
})
it('status prints no key/cert material (INV9)', async () => {
const { d, out } = deps({}, true)
await runCli({ command: 'status', flags: {} }, d)

View File

@@ -1,8 +1,52 @@
import { describe, expect, it } from 'vitest'
import { X509Certificate } from 'node:crypto'
import { generateIdentity } from '../src/keys/identity.js'
import { X509Certificate, createPublicKey, verify } from 'node:crypto'
import { generateIdentity, generateP256Identity } from '../src/keys/identity.js'
import { buildCsr } from '../src/enroll/csr.js'
// --- minimal DER reader (test-only) — walks the PKCS#10 outer SEQUENCE into its 3 children -------
interface Tlv {
readonly tag: number
/** the full tag+length+value bytes (what was signed, for the CertificationRequestInfo). */
readonly tlv: Uint8Array
readonly content: Uint8Array
}
function readTlv(buf: Uint8Array, off: number): { node: Tlv; next: number } {
const tag = buf[off]!
let i = off + 1
const first = buf[i]!
let len: number
if (first < 0x80) {
len = first
i += 1
} else {
const n = first & 0x7f
len = 0
for (let k = 0; k < n; k++) len = (len << 8) | buf[i + 1 + k]!
i += 1 + n
}
return { node: { tag, tlv: buf.subarray(off, i + len), content: buf.subarray(i, i + len) }, next: i + len }
}
function pemToDer(pem: string): Uint8Array {
const b64 = pem.replace(/-----[A-Z ]+-----/g, '').replace(/\s+/g, '')
return new Uint8Array(Buffer.from(b64, 'base64'))
}
/** The three children of the PKCS#10 outer SEQUENCE: [certificationRequestInfo, sigAlg, signature]. */
function csrChildren(pem: string): readonly Tlv[] {
const der = pemToDer(pem)
const outer = readTlv(der, 0).node
const children: Tlv[] = []
let p = 0
while (p < outer.content.length) {
const { node, next } = readTlv(outer.content, p)
children.push(node)
p = next
}
return children
}
describe('PKCS#10 CSR (T4)', () => {
it('emits a PEM CERTIFICATE REQUEST', () => {
const csr = buildCsr(generateIdentity(), 'host-42.term.example.com')
@@ -30,3 +74,50 @@ describe('PKCS#10 CSR (T4)', () => {
expect(typeof X509Certificate).toBe('function')
})
})
describe('P-256 PKCS#10 CSR (FIX H-host-2)', () => {
it('emits a PEM CERTIFICATE REQUEST for a P-256 identity', () => {
const csr = buildCsr(generateP256Identity(), 'alice.terminal.yaojia.wang')
expect(csr).toContain('-----BEGIN CERTIFICATE REQUEST-----')
expect(csr).toContain('-----END CERTIFICATE REQUEST-----')
expect(csr).not.toContain('PRIVATE KEY')
})
it('round-trips as a valid PKCS#10: signatureAlgorithm is ecdsa-with-SHA256', () => {
const csr = buildCsr(generateP256Identity(), 'alice.terminal.yaojia.wang')
const [, sigAlg] = csrChildren(csr)
// sigAlg = SEQUENCE { OID 1.2.840.10045.4.3.2 } (no parameters, RFC 5758 §3.2)
const oidTlv = readTlv(sigAlg!.content, 0).node
expect(Array.from(oidTlv.content)).toEqual([0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x02])
// no parameters: the sigAlg SEQUENCE holds ONLY the OID.
expect(oidTlv.tlv.length).toBe(sigAlg!.content.length)
})
it('self-signature verifies over the CertificationRequestInfo (verifyCsrPoPEc semantics)', () => {
const id = generateP256Identity()
const csr = buildCsr(id, 'alice.terminal.yaojia.wang')
const [reqInfo, , sigVal] = csrChildren(csr)
// signatureValue BIT STRING content = 0x00 (unused bits) || DER ECDSA-Sig-Value.
expect(sigVal!.tag).toBe(0x03)
const signature = sigVal!.content.subarray(1)
const pub = createPublicKey({ key: Buffer.from(id.publicKey), format: 'der', type: 'spki' })
// Verify over the EXACT CertificationRequestInfo bytes that buildCsr signed.
expect(verify('sha256', reqInfo!.tlv, pub, signature)).toBe(true)
// Tampering with the signed body breaks PoP.
const tampered = Uint8Array.from(reqInfo!.tlv)
tampered[tampered.length - 1] = tampered[tampered.length - 1]! ^ 0xff
expect(verify('sha256', tampered, pub, signature)).toBe(false)
})
it('embeds the identity EC SPKI verbatim as the CSR subjectPublicKeyInfo', () => {
const id = generateP256Identity()
const csr = buildCsr(id, 'alice.terminal.yaojia.wang')
const [reqInfo] = csrChildren(csr)
// requestInfo = SEQUENCE { version, name, spki, [0] attributes }; the spki is the 3rd child.
const inner = reqInfo!.content
const version = readTlv(inner, 0)
const name = readTlv(inner, version.next)
const spki = readTlv(inner, name.next).node
expect(Buffer.from(spki.tlv).equals(Buffer.from(id.publicKey))).toBe(true)
})
})

103
agent/test/deps.test.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* B4/H4 wiring test — closes the frpc-log capture loop that `HealthReport.healthy` depends on.
*
* Regression guarded: nothing used to WRITE `<stateDir>/frpc.log`, so `readFrpcLog` always returned
* '' and `frpcProxyStarted` was permanently false — health could never be true. This exercises the
* real production path end-to-end: `createFileLoggingSpawn` (the spawn `superviseNative` injects)
* tees a child's stdout into the exact file `readFrpcLog` scans, and `frpcProxyStarted` detects the
* "start proxy success" line. Writer path and reader path share `frpcLogPath`, so they can't diverge.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createFileLoggingSpawn, type FrpcChild } from '../src/transport/frpSupervise.js'
import { frpcLogPath, readFrpcLog } from '../src/cli/deps.js'
import { frpcProxyStarted } from '../src/health/probe.js'
const SUCCESS_LINE = '[web-terminal] start proxy success'
/** Poll `predicate` until true or `timeoutMs` elapses (real IO flush is async). */
async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (predicate()) return true
await new Promise((r) => setTimeout(r, 25))
}
return predicate()
}
/** Write an executable fake `frpc` (node shebang) that repeatedly prints the success line to stdout. */
function writeFakeFrpc(dir: string): string {
const bin = join(dir, 'fake-frpc.mjs')
const script =
`#!${process.execPath}\n` +
`process.stdout.write(${JSON.stringify(`${SUCCESS_LINE}\n`)})\n` +
`setInterval(() => process.stdout.write(${JSON.stringify(`${SUCCESS_LINE}\n`)}), 100)\n`
writeFileSync(bin, script, { mode: 0o755 })
return bin
}
describe('B4/H4 frpc-log capture wiring (createFileLoggingSpawn ↔ readFrpcLog)', () => {
const dirs: string[] = []
let child: FrpcChild | null = null
afterEach(() => {
child?.kill()
child = null
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
})
it('tees the frpc child stdout into the file readFrpcLog scans → proxyStarted becomes true', async () => {
const dir = mkdtempSync(join(tmpdir(), 'frpclog-'))
dirs.push(dir)
// Before any child runs, the log is empty and the proxy is not started.
expect(readFrpcLog(dir)).toBe('')
expect(frpcProxyStarted(readFrpcLog(dir))).toBe(false)
// Spawn via the SAME factory superviseNative injects, pointed at the SAME path it reads.
const bin = writeFakeFrpc(dir)
const spawn = createFileLoggingSpawn(frpcLogPath(dir))
child = spawn(bin, join(dir, 'frpc.toml'))
const detected = await waitFor(() => frpcProxyStarted(readFrpcLog(dir)))
expect(detected).toBe(true)
expect(readFrpcLog(dir)).toContain('start proxy success')
})
it('createFileLoggingSpawn truncates a stale log so a dead childs success line is not reused', async () => {
const dir = mkdtempSync(join(tmpdir(), 'frpclog-'))
dirs.push(dir)
// Simulate a leftover log from a previous (now dead) frpc that had succeeded.
writeFileSync(frpcLogPath(dir), `${SUCCESS_LINE}\n`)
expect(frpcProxyStarted(readFrpcLog(dir))).toBe(true)
// A fresh spawn whose child never prints the line must NOT keep reporting the stale success.
const bin = join(dir, 'silent-frpc.mjs')
writeFileSync(bin, `#!${process.execPath}\nsetInterval(() => {}, 100)\n`, { mode: 0o755 })
const spawn = createFileLoggingSpawn(frpcLogPath(dir))
child = spawn(bin, join(dir, 'frpc.toml'))
// Truncate-on-spawn clears the stale line; the silent child adds none.
const cleared = await waitFor(() => readFrpcLog(dir) === '')
expect(cleared).toBe(true)
expect(frpcProxyStarted(readFrpcLog(dir))).toBe(false)
})
})
describe('frpcLogPath / readFrpcLog (deterministic)', () => {
it('frpcLogPath joins frpc.log under the state dir', () => {
expect(frpcLogPath('/state/x')).toBe(join('/state/x', 'frpc.log'))
})
it('readFrpcLog returns "" when the log file does not exist', () => {
const dir = mkdtempSync(join(tmpdir(), 'frpclog-'))
try {
expect(readFrpcLog(dir)).toBe('')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})

View File

@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from 'vitest'
import { createLogger } from '../src/log/logger.js'
import { createBackoff } from '../src/transport/backoff.js'
import {
STABLE_RUN_MS,
superviseFrpc,
type FrpcChild,
type SpawnFrpc,
} from '../src/transport/frpSupervise.js'
const silentLogger = createLogger('error', () => {})
/**
* A fake frpc child whose exit is driven from the test. `kill()` models a real process: it dies,
* firing the exit handler (so the supervisor's `stop()` — which kills the live child — can unblock).
* exit/kill fire the handler at most once.
*/
function makeChild(): { child: FrpcChild; exit: (code: number | null) => void; killed: boolean } {
let onExit: ((code: number | null) => void) | null = null
let alive = true
const state = { killed: false }
const fire = (code: number | null): void => {
if (!alive) return
alive = false
onExit?.(code)
}
return {
child: {
onExit: (cb) => {
onExit = cb
},
isAlive: () => alive,
kill: () => {
state.killed = true
fire(null)
},
},
exit: (code) => fire(code),
get killed() {
return state.killed
},
}
}
describe('superviseFrpc (B4/H4 — restart-on-exit backoff)', () => {
it('spawns the frpc binary with -c <toml> via the child seam', async () => {
const spawn: SpawnFrpc = vi.fn(() => makeChild().child)
superviseFrpc('/opt/agent/bin/frpc', '/state/frpc.toml', {
spawn,
sleep: async () => {},
logger: silentLogger,
})
await Promise.resolve()
expect(spawn).toHaveBeenCalledWith('/opt/agent/bin/frpc', '/state/frpc.toml')
})
it('restarts the child on exit, backing off 1s → 2s → 4s', async () => {
const children = [makeChild(), makeChild(), makeChild(), makeChild()]
let n = 0
const spawn: SpawnFrpc = () => children[n++]!.child
const sleeps: number[] = []
// now() fixed so no run counts as "stable" ⇒ backoff monotonically increases.
const handle = superviseFrpc('/frpc', '/toml', {
spawn,
backoff: createBackoff(),
sleep: async (ms) => {
sleeps.push(ms)
},
logger: silentLogger,
now: () => 1000,
})
// Crash three times; each crash schedules the next spawn after the growing backoff.
for (let i = 0; i < 3; i += 1) {
children[i]!.exit(1)
await Promise.resolve()
await Promise.resolve()
}
expect(sleeps).toEqual([1000, 2000, 4000])
await handle.stop()
})
it('resets the backoff after a run that stayed up past the stability window', async () => {
const children = [makeChild(), makeChild(), makeChild()]
let n = 0
const spawn: SpawnFrpc = () => children[n++]!.child
const sleeps: number[] = []
let clock = 0
const handle = superviseFrpc('/frpc', '/toml', {
spawn,
backoff: createBackoff(),
sleep: async (ms) => {
sleeps.push(ms)
},
logger: silentLogger,
now: () => clock,
})
// First run crashes instantly ⇒ backoff 1s.
children[0]!.exit(1)
await Promise.resolve()
await Promise.resolve()
// Second run stays up past STABLE_RUN_MS before dying ⇒ backoff resets to 1s (not 2s).
clock += STABLE_RUN_MS + 1
children[1]!.exit(1)
await Promise.resolve()
await Promise.resolve()
expect(sleeps).toEqual([1000, 1000])
await handle.stop()
})
it('stop() halts the loop and kills the live child; done resolves 0', async () => {
const c = makeChild()
const spawn: SpawnFrpc = () => c.child
const handle = superviseFrpc('/frpc', '/toml', {
spawn,
sleep: async () => {},
logger: silentLogger,
})
await Promise.resolve()
expect(handle.isChildAlive()).toBe(true)
// stop() kills the child; that fires the exit handler and the loop observes `stopped` → breaks.
const stopping = handle.stop()
c.exit(null)
await expect(stopping).resolves.toBeUndefined()
await expect(handle.done).resolves.toBe(0)
expect(c.killed).toBe(true)
})
it('does not restart after stop (no spawn past shutdown)', async () => {
const first = makeChild()
let n = 0
const spawn: SpawnFrpc = vi.fn(() => (n++ === 0 ? first.child : makeChild().child))
const handle = superviseFrpc('/frpc', '/toml', {
spawn,
sleep: async () => {},
logger: silentLogger,
})
await Promise.resolve()
const stopping = handle.stop()
first.exit(0)
await stopping
expect(spawn).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,416 @@
import { describe, expect, it } from 'vitest'
import { createHash } from 'node:crypto'
import { gzipSync } from 'node:zlib'
import {
detectFrpcPlatform,
FRPC_PLATFORMS,
FRPC_RELEASES,
provisionFrpc,
type FrpcPlatform,
type FrpcReleaseRef,
type ProvisionFrpcDeps,
} from '../src/provision/frpcBinary.js'
import {
extractTarFileByBasename,
extractFrpcBinary,
TarExtractError,
} from '../src/provision/untar.js'
function sha256Hex(data: Uint8Array): string {
return createHash('sha256').update(data).digest('hex')
}
// ---------------------------------------------------------------------------
// In-test tar/gzip fixture builder — hand-builds real USTAR blocks so the
// extractor is exercised against the SAME on-disk layout frp ships (dir entry
// + regular files), with zero network access.
// ---------------------------------------------------------------------------
const TAR_BLOCK = 512
const TYPE_FILE = '0'
const TYPE_DIR = '5'
interface TarEntrySpec {
readonly name: string
readonly content: Uint8Array
readonly typeflag?: string
}
function writeOctalField(block: Uint8Array, offset: number, len: number, value: number): void {
const s = value.toString(8).padStart(len - 1, '0')
block.set(new TextEncoder().encode(s), offset)
block[offset + len - 1] = 0 // NUL terminator
}
function tarHeader(name: string, size: number, typeflag: string): Uint8Array {
const block = new Uint8Array(TAR_BLOCK)
const enc = new TextEncoder()
block.set(enc.encode(name).subarray(0, 100), 0)
writeOctalField(block, 100, 8, 0o755) // mode
writeOctalField(block, 108, 8, 0) // uid
writeOctalField(block, 116, 8, 0) // gid
writeOctalField(block, 124, 12, size) // size
writeOctalField(block, 136, 12, 0) // mtime
block[156] = typeflag.charCodeAt(0)
block.set(enc.encode('ustar'), 257) // magic "ustar\0"
block[263] = 0x30 // version "00"
block[264] = 0x30
// checksum: fields spaces during compute, then written as 6 octal + NUL + space
for (let i = 148; i < 156; i++) block[i] = 0x20
let sum = 0
for (let i = 0; i < TAR_BLOCK; i++) sum += block[i] ?? 0
block.set(enc.encode(sum.toString(8).padStart(6, '0')), 148)
block[154] = 0
block[155] = 0x20
return block
}
function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
const total = parts.reduce((n, p) => n + p.length, 0)
const out = new Uint8Array(total)
let off = 0
for (const p of parts) {
out.set(p, off)
off += p.length
}
return out
}
function makeTar(entries: readonly TarEntrySpec[]): Uint8Array {
const parts: Uint8Array[] = []
for (const e of entries) {
parts.push(tarHeader(e.name, e.content.length, e.typeflag ?? TYPE_FILE))
parts.push(e.content)
const pad = (TAR_BLOCK - (e.content.length % TAR_BLOCK)) % TAR_BLOCK
if (pad > 0) parts.push(new Uint8Array(pad))
}
parts.push(new Uint8Array(TAR_BLOCK * 2)) // end-of-archive: two zero blocks
return concatBytes(parts)
}
function makeTarGz(entries: readonly TarEntrySpec[]): Uint8Array {
return new Uint8Array(gzipSync(makeTar(entries)))
}
const FRPC_BYTES = new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 0xde, 0xad, 0xbe, 0xef]) // fake "ELF" frpc
const FRPS_BYTES = new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x11, 0x22, 0x33]) // decoy frps
/** A realistic frp archive layout: dir entry + frpc + decoy frps + LICENSE. */
function realisticFrpTarGz(prefix = 'frp_0.61.1_darwin_arm64'): Uint8Array {
return makeTarGz([
{ name: `${prefix}/`, content: new Uint8Array(0), typeflag: TYPE_DIR },
{ name: `${prefix}/frps`, content: FRPS_BYTES },
{ name: `${prefix}/frpc`, content: FRPC_BYTES },
{ name: `${prefix}/LICENSE`, content: new TextEncoder().encode('MIT') },
])
}
interface FakeFsState {
writes: Map<string, Uint8Array>
renames: Array<{ from: string; to: string }>
removed: string[]
chmods: Array<{ path: string; mode: number }>
mkdirs: string[]
}
function makeFakeDeps(bytesByUrl: Record<string, Uint8Array>): {
deps: ProvisionFrpcDeps
state: FakeFsState
fetchedUrls: string[]
} {
const state: FakeFsState = {
writes: new Map(),
renames: [],
removed: [],
chmods: [],
mkdirs: [],
}
const fetchedUrls: string[] = []
const deps: ProvisionFrpcDeps = {
fetch: async (url) => {
fetchedUrls.push(url)
const bytes = bytesByUrl[url]
if (!bytes) throw new Error(`test: no fixture bytes for ${url}`)
return bytes
},
fs: {
mkdir: async (dir) => {
state.mkdirs.push(dir)
},
writeFile: async (path, data) => {
state.writes.set(path, data)
},
rename: async (from, to) => {
state.renames.push({ from, to })
const data = state.writes.get(from)
if (data) {
state.writes.delete(from)
state.writes.set(to, data)
}
},
chmod: async (path, mode) => {
state.chmods.push({ path, mode })
},
rm: async (path) => {
state.removed.push(path)
state.writes.delete(path)
},
},
}
return { deps, state, fetchedUrls }
}
function releaseOverride(
platform: FrpcPlatform,
ref: FrpcReleaseRef,
): Record<FrpcPlatform, FrpcReleaseRef> {
return { ...FRPC_RELEASES, [platform]: ref }
}
const BIN_DIR = '/opt/wt/bin'
const BIN_PATH = '/opt/wt/bin/frpc'
describe('detectFrpcPlatform (B3)', () => {
it('maps darwin/linux × arm64/amd64 (node x64 → amd64)', () => {
expect(detectFrpcPlatform('darwin', 'arm64')).toBe('darwin-arm64')
expect(detectFrpcPlatform('darwin', 'x64')).toBe('darwin-amd64')
expect(detectFrpcPlatform('linux', 'arm64')).toBe('linux-arm64')
expect(detectFrpcPlatform('linux', 'x64')).toBe('linux-amd64')
})
it('returns null for unsupported platform/arch', () => {
expect(detectFrpcPlatform('win32', 'x64')).toBeNull()
expect(detectFrpcPlatform('linux', 'ia32')).toBeNull()
expect(detectFrpcPlatform('freebsd', 'arm64')).toBeNull()
expect(detectFrpcPlatform('darwin', 'mips')).toBeNull()
})
})
describe('FRPC_RELEASES pinning', () => {
it('pins a release per supported platform (https url, semver, 64-hex sha256)', () => {
expect([...FRPC_PLATFORMS].sort()).toEqual([
'darwin-amd64',
'darwin-arm64',
'linux-amd64',
'linux-arm64',
])
for (const platform of FRPC_PLATFORMS) {
const ref = FRPC_RELEASES[platform]
expect(ref.url.startsWith('https://')).toBe(true)
expect(ref.url.endsWith('.tar.gz')).toBe(true)
expect(ref.version).toMatch(/^\d+\.\d+\.\d+$/)
expect(ref.sha256).toMatch(/^[0-9a-f]{64}$/)
}
})
it('pins REAL (non-placeholder) frp v0.61.1 checksums', () => {
for (const platform of FRPC_PLATFORMS) {
expect(FRPC_RELEASES[platform].sha256).not.toBe('0'.repeat(64))
}
})
})
describe('extractTarFileByBasename (tar path-traversal safe extractor)', () => {
it('returns the bytes of the first regular file whose basename matches', () => {
const tar = makeTar([
{ name: 'frp_x/', content: new Uint8Array(0), typeflag: TYPE_DIR },
{ name: 'frp_x/frps', content: FRPS_BYTES },
{ name: 'frp_x/frpc', content: FRPC_BYTES },
])
expect(extractTarFileByBasename(tar, 'frpc')).toEqual(FRPC_BYTES)
expect(extractTarFileByBasename(tar, 'frps')).toEqual(FRPS_BYTES)
})
it('does NOT match a directory entry that shares the basename', () => {
const tar = makeTar([
{ name: 'frpc/', content: new Uint8Array(0), typeflag: TYPE_DIR },
{ name: 'frp_x/frpc', content: FRPC_BYTES },
])
expect(extractTarFileByBasename(tar, 'frpc')).toEqual(FRPC_BYTES)
})
it('throws when no matching file entry exists', () => {
const tar = makeTar([{ name: 'frp_x/frps', content: FRPS_BYTES }])
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(TarExtractError)
})
it('REJECTS a matching entry whose name contains a ".." traversal segment', () => {
const tar = makeTar([{ name: 'frp_x/../../../tmp/frpc', content: FRPC_BYTES }])
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(/unsafe|traversal|\.\./i)
})
it('REJECTS a matching entry with an absolute path name', () => {
const tar = makeTar([{ name: '/etc/frpc', content: FRPC_BYTES }])
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(/unsafe|absolute/i)
})
it('throws on a corrupt (truncated) tar rather than reading out of bounds', () => {
const full = makeTar([{ name: 'frp_x/frpc', content: FRPC_BYTES }])
const truncated = full.subarray(0, TAR_BLOCK + 4) // header + partial content
expect(() => extractTarFileByBasename(truncated, 'frpc')).toThrow(TarExtractError)
})
})
describe('extractFrpcBinary (gunzip + extract)', () => {
it('gunzips then extracts the inner frpc bytes', () => {
expect(extractFrpcBinary(realisticFrpTarGz())).toEqual(FRPC_BYTES)
})
it('throws on non-gzip input', () => {
expect(() => extractFrpcBinary(new Uint8Array([1, 2, 3, 4]))).toThrow(TarExtractError)
})
})
describe('provisionFrpc (B3 verify-download + extract discipline)', () => {
it('selects the arch URL, VERIFIES the archive, and places the INNER frpc (not the archive)', async () => {
const archive = realisticFrpTarGz()
const url = 'https://example.test/frp-darwin-arm64.tar.gz'
const releases = releaseOverride('darwin-arm64', {
version: '0.61.1',
url,
sha256: sha256Hex(archive),
})
const { deps, state, fetchedUrls } = makeFakeDeps({ [url]: archive })
const result = await provisionFrpc(
{ platform: 'darwin', arch: 'arm64', binDir: BIN_DIR, releases },
deps,
)
expect(fetchedUrls).toEqual([url])
expect(result.binPath).toBe(BIN_PATH)
expect(result.version).toBe('0.61.1')
expect(result.platform).toBe('darwin-arm64')
// atomic place: renamed into the final path, made executable
expect(state.renames.some((r) => r.to === BIN_PATH)).toBe(true)
expect(state.chmods.some((c) => (c.mode & 0o111) !== 0)).toBe(true)
// the placed file is the EXTRACTED frpc binary, NOT the .tar.gz archive
const placed = state.writes.get(BIN_PATH)
expect(placed).toEqual(FRPC_BYTES)
expect(placed).not.toEqual(archive)
})
it('ignores the decoy frps entry and places frpc even when frps precedes it', async () => {
const archive = makeTarGz([
{ name: 'frp_x/frps', content: FRPS_BYTES },
{ name: 'frp_x/frpc', content: FRPC_BYTES },
])
const url = 'https://example.test/frp.tar.gz'
const releases = releaseOverride('linux-amd64', {
version: '0.61.1',
url,
sha256: sha256Hex(archive),
})
const { deps, state } = makeFakeDeps({ [url]: archive })
await provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps)
expect(state.writes.get(BIN_PATH)).toEqual(FRPC_BYTES)
})
it('REJECTS on sha256 mismatch and places NO binary (temp cleaned up, no extraction)', async () => {
const archive = realisticFrpTarGz()
const url = 'https://example.test/frp-linux-amd64.tar.gz'
const releases = releaseOverride('linux-amd64', {
version: '0.61.1',
url,
sha256: 'f'.repeat(64), // deliberately wrong
})
const { deps, state } = makeFakeDeps({ [url]: archive })
await expect(
provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps),
).rejects.toThrow(/sha-?256|hash|integrity|mismatch/i)
expect(state.renames).toEqual([])
expect(state.writes.has(BIN_PATH)).toBe(false)
expect(state.removed.length).toBeGreaterThan(0) // unverified temp removed
})
it('never places or execs before verifying (mismatch leaves nothing executable)', async () => {
const archive = realisticFrpTarGz()
const url = 'https://example.test/bad.tar.gz'
const releases = releaseOverride('linux-arm64', {
version: '0.61.1',
url,
sha256: '0'.repeat(64),
})
const { deps, state } = makeFakeDeps({ [url]: archive })
await expect(
provisionFrpc({ platform: 'linux', arch: 'arm64', binDir: BIN_DIR, releases }, deps),
).rejects.toThrow()
expect(state.chmods.every((c) => c.path !== BIN_PATH)).toBe(true)
})
it('throws and places nothing when the verified archive has NO frpc entry', async () => {
const archive = makeTarGz([
{ name: 'frp_x/frps', content: FRPS_BYTES },
{ name: 'frp_x/LICENSE', content: new TextEncoder().encode('MIT') },
])
const url = 'https://example.test/no-frpc.tar.gz'
const releases = releaseOverride('darwin-amd64', {
version: '0.61.1',
url,
sha256: sha256Hex(archive),
})
const { deps, state } = makeFakeDeps({ [url]: archive })
await expect(
provisionFrpc({ platform: 'darwin', arch: 'x64', binDir: BIN_DIR, releases }, deps),
).rejects.toThrow(/extract|frpc|tar/i)
expect(state.renames).toEqual([])
expect(state.writes.has(BIN_PATH)).toBe(false)
expect(state.removed.length).toBeGreaterThan(0) // temp removed on extraction failure
})
it('REJECTS a traversal frpc entry and never writes outside binDir', async () => {
const archive = makeTarGz([
{ name: 'frp_x/../../../tmp/frpc', content: FRPC_BYTES },
])
const url = 'https://example.test/evil.tar.gz'
const releases = releaseOverride('linux-amd64', {
version: '0.61.1',
url,
sha256: sha256Hex(archive),
})
const { deps, state } = makeFakeDeps({ [url]: archive })
await expect(
provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps),
).rejects.toThrow()
// nothing placed, and every write that ever happened stayed inside binDir
expect(state.writes.has(BIN_PATH)).toBe(false)
expect(state.renames).toEqual([])
expect([...state.writes.keys()].every((p) => p.startsWith(`${BIN_DIR}/`))).toBe(true)
expect(state.removed.every((p) => p.startsWith(`${BIN_DIR}/`))).toBe(true)
})
it('REJECTS a hash-matching but corrupt (non-gzip) archive after the gate', async () => {
const garbage = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]) // hashes fine, not a gzip
const url = 'https://example.test/corrupt.tar.gz'
const releases = releaseOverride('darwin-arm64', {
version: '0.61.1',
url,
sha256: sha256Hex(garbage),
})
const { deps, state } = makeFakeDeps({ [url]: garbage })
await expect(
provisionFrpc({ platform: 'darwin', arch: 'arm64', binDir: BIN_DIR, releases }, deps),
).rejects.toThrow(/extract|gzip|tar/i)
expect(state.writes.has(BIN_PATH)).toBe(false)
expect(state.removed.length).toBeGreaterThan(0)
})
it('throws a clear error on an unsupported platform (nothing fetched)', async () => {
const { deps, fetchedUrls } = makeFakeDeps({})
await expect(
provisionFrpc({ platform: 'win32', arch: 'x64', binDir: BIN_DIR }, deps),
).rejects.toThrow(/unsupported|platform/i)
expect(fetchedUrls).toEqual([])
})
})

100
agent/test/frpcToml.test.ts Normal file
View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import { buildNativeFrpcToml, type NativeFrpcOptions } from '../src/transport/frpcToml.js'
const BASE: NativeFrpcOptions = {
subdomain: 'alice',
localPort: 3000,
authToken: 'super-secret-token',
certFile: '/home/alice/.web-terminal-agent/frpc.cert.pem',
keyFile: '/home/alice/.web-terminal-agent/frpc.key.pem',
trustedCaFile: '/home/alice/.web-terminal-agent/frps-ctrl-ca.pem',
}
describe('buildNativeFrpcToml (B2h)', () => {
it('emits the native-tunnel server/port/tls keys (PLAN_NATIVE_TUNNEL §4)', () => {
const toml = buildNativeFrpcToml(BASE)
expect(toml).toContain('serverAddr = "8.138.1.192"')
expect(toml).toContain('serverPort = 443')
expect(toml).toContain('transport.tls.enable = true')
expect(toml).toContain('transport.tls.serverName = "frp.terminal.yaojia.wang"')
expect(toml).toContain('transport.tls.disableCustomTLSFirstByte = true')
expect(toml).toContain(`transport.tls.certFile = "${BASE.certFile}"`)
expect(toml).toContain(`transport.tls.keyFile = "${BASE.keyFile}"`)
expect(toml).toContain(`transport.tls.trustedCaFile = "${BASE.trustedCaFile}"`)
expect(toml).toContain('auth.method = "token"')
expect(toml).toContain('auth.token = "super-secret-token"')
})
it('emits a well-formed [[proxies]] http block bound to loopback', () => {
const toml = buildNativeFrpcToml(BASE)
expect(toml).toContain('[[proxies]]')
expect(toml).toContain('type = "http"')
expect(toml).toContain('subdomain = "alice"')
expect(toml).toContain('localIP = "127.0.0.1"')
expect(toml).toContain('localPort = 3000')
// the proxy block comes after the server/tls preamble
expect(toml.indexOf('[[proxies]]')).toBeGreaterThan(toml.indexOf('serverAddr'))
})
it('does NOT emit the retired v0.8 [common]/tls_enable shape', () => {
const toml = buildNativeFrpcToml(BASE)
expect(toml).not.toContain('[common]')
expect(toml).not.toContain('tls_enable')
})
it('honours a configurable serverAddr (default 8.138.1.192)', () => {
expect(buildNativeFrpcToml(BASE)).toContain('serverAddr = "8.138.1.192"')
expect(buildNativeFrpcToml({ ...BASE, serverAddr: '10.9.8.7' })).toContain(
'serverAddr = "10.9.8.7"',
)
})
it('THROWS when localIP is non-loopback (anti-SSRF hard invariant)', () => {
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '10.0.0.5' })).toThrow(/loopback/i)
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '0.0.0.0' })).toThrow(/loopback/i)
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '192.168.1.9' })).toThrow(/loopback/i)
})
it('accepts the three explicit loopback localIP forms', () => {
for (const ip of ['127.0.0.1', '::1', 'localhost']) {
expect(() => buildNativeFrpcToml({ ...BASE, localIP: ip })).not.toThrow()
}
})
it('rejects an empty or non-label-safe subdomain', () => {
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: '' })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'has space' })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: '-bad' })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'bad-' })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'a'.repeat(64) })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'a/b' })).toThrow(/subdomain/i)
})
it('rejects an out-of-range or non-integer localPort', () => {
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 0 })).toThrow(/port/i)
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 70000 })).toThrow(/port/i)
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 3000.5 })).toThrow(/port/i)
expect(() => buildNativeFrpcToml({ ...BASE, localPort: -1 })).toThrow(/port/i)
})
it('rejects missing keystore paths and an empty auth token', () => {
expect(() => buildNativeFrpcToml({ ...BASE, certFile: '' })).toThrow(/certFile/i)
expect(() => buildNativeFrpcToml({ ...BASE, keyFile: '' })).toThrow(/keyFile/i)
expect(() => buildNativeFrpcToml({ ...BASE, trustedCaFile: '' })).toThrow(/trustedCaFile/i)
expect(() => buildNativeFrpcToml({ ...BASE, authToken: '' })).toThrow(/token/i)
})
it('escapes backslashes and quotes in Windows-style paths (valid TOML basic string)', () => {
const winCert = 'C:\\Users\\alice\\.web-terminal-agent\\frpc.cert.pem'
const toml = buildNativeFrpcToml({ ...BASE, certFile: winCert })
expect(toml).toContain(
'transport.tls.certFile = "C:\\\\Users\\\\alice\\\\.web-terminal-agent\\\\frpc.cert.pem"',
)
})
it('rejects control characters in paths/token (TOML-injection guard)', () => {
expect(() => buildNativeFrpcToml({ ...BASE, authToken: 'a\nb' })).toThrow()
expect(() => buildNativeFrpcToml({ ...BASE, certFile: 'a\nb' })).toThrow()
expect(() => buildNativeFrpcToml({ ...BASE, keyFile: 'a"b\nq' })).toThrow()
})
})

View File

@@ -1,10 +1,13 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { createPublicKey, verify } from 'node:crypto'
import {
computeEnrollFpr,
generateIdentity,
generateP256Identity,
identityFromPrivatePem,
p256IdentityFromPrivatePem,
verifySignature,
} from '../src/keys/identity.js'
@@ -50,4 +53,53 @@ describe('AgentIdentity (INV4)', () => {
// No function exports raw private key bytes onto the network surface.
expect(src).not.toMatch(/exportPrivateRaw|privateKeyBytes|toRawPrivate/)
})
it('keeps the Ed25519 alg tag (no P-256 regression)', () => {
expect(generateIdentity().alg).toBe('ed25519')
})
})
describe('P-256 AgentIdentity (FIX H-host-2 — native frp-client key)', () => {
it('generates distinct EC P-256 keypairs tagged alg=p256', () => {
const a = generateP256Identity()
const b = generateP256Identity()
expect(a.alg).toBe('p256')
expect(Buffer.from(a.publicKey).equals(Buffer.from(b.publicKey))).toBe(false)
// publicKey is the EC SubjectPublicKeyInfo DER (outer SEQUENCE), importable as a public key.
expect(a.publicKey[0]).toBe(0x30)
const pub = createPublicKey({ key: Buffer.from(a.publicKey), format: 'der', type: 'spki' })
expect(pub.asymmetricKeyType).toBe('ec')
expect(pub.asymmetricKeyDetails?.namedCurve).toBe('prime256v1')
})
it('sign produces a DER ECDSA signature that verifies under SHA-256', () => {
const id = generateP256Identity()
const msg = new TextEncoder().encode('certificationRequestInfo-bytes')
const sig = id.sign(msg)
const pub = createPublicKey({ key: Buffer.from(id.publicKey), format: 'der', type: 'spki' })
expect(verify('sha256', msg, pub, sig)).toBe(true)
expect(verify('sha256', new TextEncoder().encode('tampered'), pub, sig)).toBe(false)
})
it('enrollFpr is deterministic base64url(SHA-256(spki))', () => {
const id = generateP256Identity()
expect(computeEnrollFpr(id.publicKey)).toBe(id.enrollFpr)
expect(id.enrollFpr).not.toMatch(/[+/=]/) // base64url alphabet only
})
it('reloads the same P-256 identity from PEM (keystore load path)', () => {
const id = generateP256Identity()
const pem = id.exportPrivatePkcs8Pem()
const reloaded = p256IdentityFromPrivatePem(pem)
expect(reloaded.alg).toBe('p256')
expect(Buffer.from(reloaded.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
expect(reloaded.enrollFpr).toBe(id.enrollFpr)
})
it('security: P-256 identity exposes no raw private-key getter', () => {
const id = generateP256Identity()
expect('privateKey' in id).toBe(false)
// the PEM export is the only serialization surface; it is a PRIVATE key PEM (0600 keystore only).
expect(id.exportPrivatePkcs8Pem()).toContain('PRIVATE KEY')
})
})

View File

@@ -1,15 +1,18 @@
import { describe, expect, it } from 'vitest'
import type { AgentConfig } from '../src/config/agentConfig.js'
import {
BindHostError,
RootRefusedError,
assertNativeZone,
buildInstallOptions,
detectPlatform,
installService,
normalizeBindHost,
uninstallService,
type InstallDeps,
} from '../src/service/install.js'
import { buildLaunchdPlist } from '../src/service/launchd.js'
import { buildSystemdUnit } from '../src/service/systemd.js'
import { agentLabel, baseAppLabel, buildLaunchdPlist } from '../src/service/launchd.js'
import { agentUnitName, baseAppUnitName, buildSystemdUnit } from '../src/service/systemd.js'
const CFG: AgentConfig = {
relayUrl: 'wss://relay/agent',
@@ -20,7 +23,12 @@ const CFG: AgentConfig = {
hostId: 'h-1',
}
function deps(uid = 501): InstallDeps & { writes: Array<[string, string]>; runs: Array<[string, readonly string[]]> } {
type FakeDeps = InstallDeps & {
writes: Array<[string, string]>
runs: Array<[string, readonly string[]]>
}
function deps(uid = 501): FakeDeps {
const writes: Array<[string, string]> = []
const runs: Array<[string, readonly string[]]> = []
return {
@@ -37,6 +45,13 @@ function deps(uid = 501): InstallDeps & { writes: Array<[string, string]>; runs:
}
}
/** The unit content whose path contains `needle` (e.g. `'base-app'`, `'agent'`). */
function unitWith(d: FakeDeps, needle: string): string {
const hit = d.writes.find(([path]) => path.includes(needle))
if (!hit) throw new Error(`no unit written whose path contains '${needle}'`)
return hit[1]
}
describe('detectPlatform (T17)', () => {
it('maps darwin→launchd, linux→systemd, else null', () => {
expect(detectPlatform('darwin')).toBe('launchd')
@@ -45,143 +60,157 @@ describe('detectPlatform (T17)', () => {
})
})
describe('installService (T17)', () => {
describe('installService — least privilege (T17)', () => {
it('refuses to install as root (negative, least privilege)', async () => {
await expect(installService(CFG, 'systemd', deps(0))).rejects.toBeInstanceOf(RootRefusedError)
})
it('systemd: writes a run-as-user unit and enables it', async () => {
it('root refusal emits nothing', async () => {
const d = deps(0)
await expect(installService(CFG, 'systemd', d)).rejects.toBeInstanceOf(RootRefusedError)
expect(d.writes).toHaveLength(0)
expect(d.runs).toHaveLength(0)
})
})
describe('installService — two distinct units (FIX M-host-2service)', () => {
it('systemd: writes a base-app unit AND an agent unit, both run-as-user, both enabled', async () => {
const d = deps()
await installService(CFG, 'systemd', d)
const [, unit] = d.writes[0]!
expect(unit).toContain('ExecStart=/usr/local/bin/web-terminal-agent run')
// exactly two units
expect(d.writes).toHaveLength(2)
const baseApp = unitWith(d, baseAppUnitName())
const agent = unitWith(d, agentUnitName())
// agent unit supervises frpc via `<bin> run`; base-app runs the node server (loopback)
expect(agent).toContain('ExecStart=/usr/local/bin/web-terminal-agent run')
expect(baseApp).toContain('ExecStart=')
expect(baseApp).toContain('server.js')
expect(baseApp).not.toContain('web-terminal-agent run')
// both least-privilege + restart-on-failure
for (const unit of [baseApp, agent]) {
expect(unit).toContain('User=alice')
expect(unit).not.toContain('User=root')
expect(unit).toContain('Restart=on-failure')
expect(d.runs[0]![0]).toBe('systemctl')
}
// both enabled
expect(d.runs.every(([cmd]) => cmd === 'systemctl')).toBe(true)
expect(d.runs).toHaveLength(2)
})
it('launchd: writes a plist with ProgramArguments run + KeepAlive', async () => {
it('launchd: writes a base-app plist AND an agent plist, both with KeepAlive, both loaded', async () => {
const d = deps()
await installService(CFG, 'launchd', d)
const [path, plist] = d.writes[0]!
expect(path).toContain('LaunchAgents')
expect(plist).toContain('<string>run</string>')
expect(d.writes).toHaveLength(2)
const baseApp = unitWith(d, baseAppLabel())
const agent = unitWith(d, agentLabel())
expect(agent).toContain('<string>run</string>')
expect(baseApp).toContain('server.js')
expect(baseApp).not.toContain('<string>run</string>')
for (const plist of [baseApp, agent]) {
expect(plist).toContain('<key>KeepAlive</key>')
expect(d.runs[0]![0]).toBe('launchctl')
}
expect(d.writes.every(([path]) => path.includes('LaunchAgents'))).toBe(true)
expect(d.runs.every(([cmd]) => cmd === 'launchctl')).toBe(true)
expect(d.runs).toHaveLength(2)
})
it('uninstall unloads cleanly', async () => {
it('routes base-app env to the base-app unit ONLY (never onto the agent unit)', async () => {
const d = deps()
await installService(CFG, 'systemd', d, { env: { BIND_HOST: '127.0.0.1', PORT: '3000' } })
const baseApp = unitWith(d, baseAppUnitName())
const agent = unitWith(d, agentUnitName())
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
expect(baseApp).toContain('Environment="PORT=3000"')
// the agent unit must NOT carry the base-app env
expect(agent).not.toContain('BIND_HOST')
expect(agent).not.toContain('PORT=3000')
})
it('uninstall tears down BOTH units (launchd unload)', async () => {
const d = deps()
await uninstallService('launchd', d)
expect(d.runs[0]).toEqual(['launchctl', ['unload', '/home/alice/Library/LaunchAgents/com.web-terminal.agent.plist']])
})
const targets = d.runs.map(([, args]) => args[args.length - 1])
expect(d.runs.every(([cmd]) => cmd === 'launchctl')).toBe(true)
expect(targets.some((t) => t?.includes(baseAppLabel()))).toBe(true)
expect(targets.some((t) => t?.includes(agentLabel()))).toBe(true)
})
const TUNNEL_ENV = {
BIND_HOST: '127.0.0.1',
ALLOWED_ORIGINS: 'https://t1.terminal.yaojia.wang',
PORT: '3000',
} as const
describe('env injection into the writers (PLAN_NATIVE_TUNNEL S2)', () => {
it('launchd: default (no options) omits the EnvironmentVariables block', async () => {
it('uninstall tears down BOTH units (systemd disable)', async () => {
const d = deps()
await installService(CFG, 'launchd', d)
const [, plist] = d.writes[0]!
expect(plist).not.toContain('EnvironmentVariables')
await uninstallService('systemd', d)
const units = d.runs.map(([, args]) => args[args.length - 1])
expect(d.runs.every(([cmd]) => cmd === 'systemctl')).toBe(true)
expect(units).toContain(baseAppUnitName())
expect(units).toContain(agentUnitName())
})
})
it('launchd: injects a sorted, XML-escaped EnvironmentVariables dict', async () => {
describe('BIND_HOST loopback S-GATE (FIX C-host-1, CRITICAL)', () => {
it('normalizeBindHost defaults an absent value to loopback', () => {
expect(normalizeBindHost(undefined)).toBe('127.0.0.1')
expect(normalizeBindHost('')).toBe('127.0.0.1')
})
it('normalizeBindHost accepts loopback forms (127.0.0.0/8, ::1, localhost)', () => {
expect(normalizeBindHost('127.0.0.1')).toBe('127.0.0.1')
expect(normalizeBindHost('127.0.0.2')).toBe('127.0.0.2')
expect(normalizeBindHost('::1')).toBe('::1')
expect(normalizeBindHost('localhost')).toBe('localhost')
})
it('normalizeBindHost REJECTS 0.0.0.0 and other non-loopback values', () => {
expect(() => normalizeBindHost('0.0.0.0')).toThrow(BindHostError)
expect(() => normalizeBindHost('192.168.1.10')).toThrow(BindHostError)
expect(() => normalizeBindHost('::')).toThrow(BindHostError)
})
it('REGRESSION: rejects a suffixed hostname that merely starts with 127. (S-GATE bypass)', () => {
// These are hostnames, not loopback literals — Node would DNS-resolve them before bind().
expect(() => normalizeBindHost('127.0.0.1.attacker.example.com')).toThrow(BindHostError)
expect(() => normalizeBindHost('127.evil.net')).toThrow(BindHostError)
expect(() => normalizeBindHost('127.0.0.1x')).toThrow(BindHostError)
expect(() => buildInstallOptions({ BIND_HOST: '127.0.0.1.attacker.example.com' })).toThrow(
BindHostError,
)
})
it('buildInstallOptions throws on BIND_HOST=0.0.0.0 (fail-closed at env read)', () => {
expect(() => buildInstallOptions({ BIND_HOST: '0.0.0.0' })).toThrow(BindHostError)
})
it('NEGATIVE: installService with BIND_HOST=0.0.0.0 throws AND emits nothing', async () => {
const d = deps()
await installService(CFG, 'launchd', d, { env: TUNNEL_ENV })
const [, plist] = d.writes[0]!
expect(plist).toContain('<key>EnvironmentVariables</key>')
expect(plist).toContain('<key>BIND_HOST</key>')
expect(plist).toContain('<string>127.0.0.1</string>')
// keys are sorted (ALLOWED_ORIGINS before BIND_HOST before PORT)
expect(plist.indexOf('ALLOWED_ORIGINS')).toBeLessThan(plist.indexOf('BIND_HOST'))
expect(plist.indexOf('BIND_HOST')).toBeLessThan(plist.indexOf('>PORT<'))
await expect(
installService(CFG, 'systemd', d, { env: { BIND_HOST: '0.0.0.0', PORT: '3000' } }),
).rejects.toBeInstanceOf(BindHostError)
expect(d.writes).toHaveLength(0)
expect(d.runs).toHaveLength(0)
})
it('launchd: escapes XML-significant characters in env values', () => {
const plist = buildLaunchdPlist('/bin/agent', { X: `a&b<c>d"e'f` })
expect(plist).toContain('<string>a&amp;b&lt;c&gt;d&quot;e&apos;f</string>')
expect(plist).not.toContain('a&b<c>d')
})
it('systemd: default (no options) omits Environment lines', async () => {
it('NEGATIVE (launchd): a 0.0.0.0 install emits no plist', async () => {
const d = deps()
await installService(CFG, 'systemd', d)
const [, unit] = d.writes[0]!
expect(unit).not.toContain('Environment')
await expect(
installService(CFG, 'launchd', d, { env: { BIND_HOST: '0.0.0.0' } }),
).rejects.toBeInstanceOf(BindHostError)
expect(d.writes).toHaveLength(0)
})
it('systemd: emits sorted, quoted Environment= lines from the env map', async () => {
it('the emitted base-app unit can NEVER contain BIND_HOST=0.0.0.0 (normalized when absent)', async () => {
const d = deps()
await installService(CFG, 'systemd', d, { env: TUNNEL_ENV })
const [, unit] = d.writes[0]!
expect(unit).toContain('Environment="BIND_HOST=127.0.0.1"')
expect(unit).toContain('Environment="PORT=3000"')
expect(unit.indexOf('ALLOWED_ORIGINS')).toBeLessThan(unit.indexOf('BIND_HOST'))
})
it('systemd: emits EnvironmentFile= (before inline Environment) when a path is given', async () => {
const d = deps()
await installService(CFG, 'systemd', d, { env: TUNNEL_ENV, envFile: '/etc/web-terminal.env' })
const [, unit] = d.writes[0]!
expect(unit).toContain('EnvironmentFile=/etc/web-terminal.env')
expect(unit.indexOf('EnvironmentFile=')).toBeLessThan(unit.indexOf('Environment='))
})
it('systemd: escapes backslash and double-quote in Environment values', () => {
const unit = buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a"b\\c' } })
expect(unit).toContain('Environment="X=a\\"b\\\\c"')
await installService(CFG, 'systemd', d, { env: { PORT: '3000' } })
const baseApp = unitWith(d, baseAppUnitName())
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
expect(baseApp).not.toContain('0.0.0.0')
})
})
describe('tunnel-origin derivation (PLAN_NATIVE_TUNNEL S2)', () => {
it('merges https://<subdomain>.<zone>.<domain> into ALLOWED_ORIGINS when domain is given', async () => {
const d = deps()
await installService(CFG, 'launchd', d, { domain: 'yaojia.wang', zone: 'terminal' })
const [, plist] = d.writes[0]!
expect(plist).toContain('<key>ALLOWED_ORIGINS</key>')
expect(plist).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
})
it('defaults to the `term` zone when only a domain is supplied', async () => {
const d = deps()
await installService(CFG, 'launchd', d, { domain: 'yaojia.wang' })
const [, plist] = d.writes[0]!
expect(plist).toContain('<string>https://host-42.term.yaojia.wang</string>')
})
it('preserves a caller-provided ALLOWED_ORIGINS and appends the tunnel origin', async () => {
const d = deps()
await installService(CFG, 'systemd', d, {
env: { ALLOWED_ORIGINS: 'https://keep.me' },
domain: 'yaojia.wang',
zone: 'terminal',
})
const [, unit] = d.writes[0]!
expect(unit).toContain('https://keep.me,https://host-42.terminal.yaojia.wang')
})
it('does not derive an origin when the config has no subdomain', async () => {
const d = deps()
await installService({ ...CFG, subdomain: null }, 'launchd', d, { domain: 'yaojia.wang' })
const [, plist] = d.writes[0]!
expect(plist).not.toContain('ALLOWED_ORIGINS')
})
})
describe('buildInstallOptions — env → InstallOptions (PLAN_NATIVE_TUNNEL S0/S2)', () => {
describe('buildInstallOptions — env → InstallOptions (S0/S2 + S-GATE)', () => {
it('defaults BIND_HOST to loopback so a tunnel install is never LAN-exposed (S0/R2)', () => {
const options = buildInstallOptions({})
expect(options.env).toEqual({ BIND_HOST: '127.0.0.1' })
})
it('honours an explicit BIND_HOST and passes through the S0 base-app env vars', () => {
it('honours an explicit loopback BIND_HOST and passes through the S0 base-app env vars', () => {
const options = buildInstallOptions({
BIND_HOST: '127.0.0.2',
PORT: '3000',
@@ -189,6 +218,8 @@ describe('buildInstallOptions — env → InstallOptions (PLAN_NATIVE_TUNNEL S0/
IDLE_TTL: '86400',
USE_TMUX: '1',
ALLOWED_ORIGINS: 'https://keep.me',
SCROLLBACK_BYTES: '2097152',
MAX_PAYLOAD_BYTES: '1048576',
})
expect(options.env).toEqual({
BIND_HOST: '127.0.0.2',
@@ -197,6 +228,17 @@ describe('buildInstallOptions — env → InstallOptions (PLAN_NATIVE_TUNNEL S0/
IDLE_TTL: '86400',
USE_TMUX: '1',
ALLOWED_ORIGINS: 'https://keep.me',
SCROLLBACK_BYTES: '2097152',
MAX_PAYLOAD_BYTES: '1048576',
})
})
it('AG3: passes SCROLLBACK_BYTES and MAX_PAYLOAD_BYTES through as base-app config', () => {
const options = buildInstallOptions({ SCROLLBACK_BYTES: '2097152', MAX_PAYLOAD_BYTES: '1048576' })
expect(options.env).toEqual({
BIND_HOST: '127.0.0.1',
SCROLLBACK_BYTES: '2097152',
MAX_PAYLOAD_BYTES: '1048576',
})
})
@@ -212,7 +254,11 @@ describe('buildInstallOptions — env → InstallOptions (PLAN_NATIVE_TUNNEL S0/
})
it('lets TUNNEL_ZONE override the origin zone and carries AGENT_ENV_FILE through', () => {
const options = buildInstallOptions({ TUNNEL_DOMAIN: 'yaojia.wang', TUNNEL_ZONE: 'term', AGENT_ENV_FILE: '/etc/wt.env' })
const options = buildInstallOptions({
TUNNEL_DOMAIN: 'yaojia.wang',
TUNNEL_ZONE: 'term',
AGENT_ENV_FILE: '/etc/wt.env',
})
expect(options.zone).toBe('term')
expect(options.envFile).toBe('/etc/wt.env')
})
@@ -224,46 +270,151 @@ describe('buildInstallOptions — env → InstallOptions (PLAN_NATIVE_TUNNEL S0/
})
})
describe('install CLI seam end-to-end — resolved env reaches the units (PLAN_NATIVE_TUNNEL S2)', () => {
// Env the operator would export before `web-terminal-agent install` on a tunnel host.
describe('tunnel-origin derivation into the base-app unit (FIX L-host-zone)', () => {
it('assertNativeZone accepts `terminal` and rejects `term`/undefined', () => {
expect(() => assertNativeZone('terminal')).not.toThrow()
expect(() => assertNativeZone('term')).toThrow(/terminal/)
expect(() => assertNativeZone(undefined)).toThrow(/terminal/)
})
it('merges https://<sub>.terminal.<domain> into the base-app ALLOWED_ORIGINS', async () => {
const d = deps()
await installService(CFG, 'launchd', d, { domain: 'yaojia.wang', zone: 'terminal' })
const baseApp = unitWith(d, baseAppLabel())
expect(baseApp).toContain('<key>ALLOWED_ORIGINS</key>')
expect(baseApp).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
})
it('preserves a caller-provided ALLOWED_ORIGINS and appends the tunnel origin', async () => {
const d = deps()
await installService(CFG, 'systemd', d, {
env: { ALLOWED_ORIGINS: 'https://keep.me' },
domain: 'yaojia.wang',
zone: 'terminal',
})
const baseApp = unitWith(d, baseAppUnitName())
expect(baseApp).toContain('https://keep.me,https://host-42.terminal.yaojia.wang')
})
it('does not derive an origin when the config has no subdomain', async () => {
const d = deps()
await installService({ ...CFG, subdomain: null }, 'launchd', d, {
domain: 'yaojia.wang',
zone: 'terminal',
})
const baseApp = unitWith(d, baseAppLabel())
expect(baseApp).not.toContain('ALLOWED_ORIGINS')
})
})
describe('install CLI seam end-to-end — resolved env reaches the base-app unit (S2)', () => {
const ENV = { PORT: '3000', SHELL_PATH: '/bin/zsh', TUNNEL_DOMAIN: 'yaojia.wang' } as const
it('launchd: the plist carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
it('launchd: the base-app plist carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
const d = deps()
await installService(CFG, 'launchd', d, buildInstallOptions(ENV))
const [, plist] = d.writes[0]!
expect(plist).toContain('<key>EnvironmentVariables</key>')
expect(plist).toContain('<key>BIND_HOST</key>')
expect(plist).toContain('<string>127.0.0.1</string>')
expect(plist).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
expect(plist).toContain('<key>PORT</key>')
expect(plist).not.toContain('0.0.0.0')
const baseApp = unitWith(d, baseAppLabel())
expect(baseApp).toContain('<key>BIND_HOST</key>')
expect(baseApp).toContain('<string>127.0.0.1</string>')
expect(baseApp).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
expect(baseApp).toContain('<key>PORT</key>')
expect(baseApp).not.toContain('0.0.0.0')
})
it('systemd: the unit carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
it('systemd: the base-app unit carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
const d = deps()
await installService(CFG, 'systemd', d, buildInstallOptions(ENV))
const [, unit] = d.writes[0]!
expect(unit).toContain('Environment="BIND_HOST=127.0.0.1"')
expect(unit).toContain('Environment="ALLOWED_ORIGINS=https://host-42.terminal.yaojia.wang"')
expect(unit).toContain('Environment="PORT=3000"')
expect(unit).not.toContain('0.0.0.0')
const baseApp = unitWith(d, baseAppUnitName())
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
expect(baseApp).toContain('Environment="ALLOWED_ORIGINS=https://host-42.terminal.yaojia.wang"')
expect(baseApp).toContain('Environment="PORT=3000"')
expect(baseApp).not.toContain('0.0.0.0')
})
it('systemd: emits EnvironmentFile= (before inline Environment) on the base-app unit', async () => {
const d = deps()
await installService(CFG, 'systemd', d, {
env: { BIND_HOST: '127.0.0.1', PORT: '3000' },
envFile: '/etc/web-terminal.env',
})
const baseApp = unitWith(d, baseAppUnitName())
expect(baseApp).toContain('EnvironmentFile=/etc/web-terminal.env')
expect(baseApp.indexOf('EnvironmentFile=')).toBeLessThan(baseApp.indexOf('Environment='))
})
})
describe('systemd env value hardening (LOW: control-char injection)', () => {
it('rejects a newline in an env value so it cannot inject a [Service] directive', () => {
expect(() => buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a\nExecStartPre=/x' } })).toThrow(
describe('unit writers — escaping & control-char hardening', () => {
it('launchd: escapes XML-significant characters in env values', () => {
const plist = buildLaunchdPlist(['/bin/agent', 'run'], { X: `a&b<c>d"e'f` })
expect(plist).toContain('<string>a&amp;b&lt;c&gt;d&quot;e&apos;f</string>')
expect(plist).not.toContain('a&b<c>d')
})
it('launchd: injects a sorted, XML-escaped EnvironmentVariables dict', () => {
const plist = buildLaunchdPlist(['/bin/agent', 'run'], {
BIND_HOST: '127.0.0.1',
ALLOWED_ORIGINS: 'https://a',
PORT: '3000',
})
expect(plist).toContain('<key>EnvironmentVariables</key>')
expect(plist.indexOf('ALLOWED_ORIGINS')).toBeLessThan(plist.indexOf('BIND_HOST'))
expect(plist.indexOf('BIND_HOST')).toBeLessThan(plist.indexOf('>PORT<'))
})
it('launchd: no env → no EnvironmentVariables block', () => {
const plist = buildLaunchdPlist(['/bin/agent', 'run'])
expect(plist).not.toContain('EnvironmentVariables')
})
it('systemd: escapes backslash and double-quote in Environment values', () => {
const unit = buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a"b\\c' } })
expect(unit).toContain('Environment="X=a\\"b\\\\c"')
})
it('systemd: rejects a newline in an env value (no [Service] directive injection)', () => {
expect(() =>
buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a\nExecStartPre=/x' } }),
).toThrow(/control character/)
})
it('systemd: rejects a carriage return in an env value', () => {
expect(() => buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a\rb' } })).toThrow(
/control character/,
)
})
it('rejects a carriage return in an env value', () => {
expect(() => buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a\rb' } })).toThrow(/control character/)
it('AG2: rejects a newline in the ExecStart command (no [Service] directive injection)', () => {
expect(() =>
buildSystemdUnit('/bin/agent run\nExecStartPre=/x', 'alice'),
).toThrow(/control character/)
})
it('still accepts ordinary values with quotes and backslashes', () => {
const unit = buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a"b\\c' } })
expect(unit).toContain('Environment="X=a\\"b\\\\c"')
it('AG2: rejects a newline in the User field', () => {
expect(() => buildSystemdUnit('/bin/agent run', 'alice\nExecStartPre=/x')).toThrow(
/control character/,
)
})
it('AG2: rejects a newline in the Description field', () => {
expect(() =>
buildSystemdUnit('/bin/agent run', 'alice', {}, 'desc\n[Service]\nExecStartPre=/x'),
).toThrow(/control character/)
})
it('AG2: rejects a newline in the EnvironmentFile path', () => {
expect(() =>
buildSystemdUnit('/bin/agent run', 'alice', { envFile: '/etc/x.env\nExecStartPre=/y' }),
).toThrow(/control character/)
})
it('AG2: rejects a newline in an Environment KEY (not just the value)', () => {
expect(() =>
buildSystemdUnit('/bin/agent run', 'alice', { env: { 'X\nExecStartPre=/y': 'v' } }),
).toThrow(/control character/)
})
it('systemd: default (no env) omits Environment lines', () => {
const unit = buildSystemdUnit('/bin/agent run', 'alice')
expect(unit).not.toContain('Environment')
})
})

View File

@@ -2,7 +2,8 @@ import { afterEach, describe, expect, it } from 'vitest'
import { mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { generateIdentity } from '../src/keys/identity.js'
import { createPublicKey, generateKeyPairSync, verify } from 'node:crypto'
import { generateIdentity, generateP256Identity } from '../src/keys/identity.js'
import { KeystoreError, openKeystore } from '../src/keys/keystore.js'
const dirs: string[] = []
@@ -67,4 +68,50 @@ describe('Keystore (INV4/INV5)', () => {
mkdirSync(dir, { mode: 0o755 })
expect(() => openKeystore(dir).saveIdentity(generateIdentity())).toThrow(KeystoreError)
})
it('keeps the Ed25519 identity byte-identical across save/load (no P-256 regression)', () => {
const dir = freshDir()
const ks = openKeystore(dir)
const id = generateIdentity()
ks.saveIdentity(id)
const reloaded = ks.loadIdentity()
expect(reloaded!.alg).toBe('ed25519')
expect(Buffer.from(reloaded!.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
expect(reloaded!.enrollFpr).toBe(id.enrollFpr)
})
it('FIX H-host-2: round-trips a P-256 frp-client identity (alg + usable signing key)', () => {
const dir = freshDir()
const ks = openKeystore(dir)
const id = generateP256Identity()
ks.saveIdentity(id)
expect(mode(join(dir, 'agent.key.pem'))).toBe(0o600)
const reloaded = ks.loadIdentity()
expect(reloaded).not.toBeNull()
// loadIdentity() branched on the stored key's alg discriminant → P-256, not Ed25519.
expect(reloaded!.alg).toBe('p256')
// EC SubjectPublicKeyInfo DER (outer SEQUENCE) preserved exactly.
expect(reloaded!.publicKey[0]).toBe(0x30)
expect(Buffer.from(reloaded!.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
expect(reloaded!.enrollFpr).toBe(id.enrollFpr)
// The reloaded key still signs: a DER ECDSA signature that verifies under the original pubkey.
const msg = new TextEncoder().encode('csr-bytes')
const sig = reloaded!.sign(msg)
const pub = createPublicKey({ key: Buffer.from(id.publicKey), format: 'der', type: 'spki' })
expect(verify('sha256', msg, pub, sig)).toBe(true)
})
it('AG1: rejects a stored EC key on a non-P256 curve (e.g. secp384r1) with a clear error', () => {
const dir = freshDir()
const ks = openKeystore(dir)
// Plant a valid PKCS#8 EC key on the WRONG curve directly at the key path — `ec` alone must not
// be mistaken for P-256; loadIdentity must assert the named curve and fail closed.
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'secp384r1' })
const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
writeFileSync(join(dir, 'agent.key.pem'), pem, { mode: 0o600 })
expect(() => ks.loadIdentity()).toThrow(KeystoreError)
expect(() => ks.loadIdentity()).toThrow(/prime256v1|P-256|curve/)
})
})

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { isLoopbackHostLiteral } from '../src/net/loopbackLiteral.js'
describe('isLoopbackHostLiteral — strict loopback-literal check (FIX C-host-1 / anti-SSRF)', () => {
it('accepts exact loopback literals', () => {
expect(isLoopbackHostLiteral('localhost')).toBe(true)
expect(isLoopbackHostLiteral('::1')).toBe(true)
expect(isLoopbackHostLiteral('[::1]')).toBe(true)
})
it('accepts any well-formed IPv4 in 127.0.0.0/8', () => {
expect(isLoopbackHostLiteral('127.0.0.1')).toBe(true)
expect(isLoopbackHostLiteral('127.0.0.2')).toBe(true)
expect(isLoopbackHostLiteral('127.5.5.5')).toBe(true)
expect(isLoopbackHostLiteral('127.255.255.255')).toBe(true)
})
it('rejects non-loopback IPs and wildcards', () => {
expect(isLoopbackHostLiteral('0.0.0.0')).toBe(false)
expect(isLoopbackHostLiteral('192.168.1.10')).toBe(false)
expect(isLoopbackHostLiteral('10.0.0.5')).toBe(false)
expect(isLoopbackHostLiteral('::')).toBe(false)
})
it('REJECTS suffixed hostnames that merely start with 127. (the S-GATE bypass)', () => {
expect(isLoopbackHostLiteral('127.0.0.1.attacker.example.com')).toBe(false)
expect(isLoopbackHostLiteral('127.evil.net')).toBe(false)
expect(isLoopbackHostLiteral('127.0.0.1.evil.example.com')).toBe(false)
expect(isLoopbackHostLiteral('127.0.0.1x')).toBe(false)
})
it('rejects malformed / non-dotted-quad partials and out-of-range octets', () => {
expect(isLoopbackHostLiteral('127.1')).toBe(false)
expect(isLoopbackHostLiteral('127.0.0.256')).toBe(false)
expect(isLoopbackHostLiteral('0127.0.0.1')).toBe(false)
expect(isLoopbackHostLiteral('')).toBe(false)
expect(isLoopbackHostLiteral('127')).toBe(false)
})
})

216
agent/test/probe.test.ts Normal file
View File

@@ -0,0 +1,216 @@
import { describe, expect, it, vi } from 'vitest'
import {
DEFAULT_CERT_RENEW_WINDOW_MS,
certIsFresh,
frpcProxyStarted,
probeLoopbackBaseApp,
renderHealthStatus,
runHealthProbe,
startHealthMonitor,
type HealthProbeSeams,
type HealthReport,
type IntervalTimer,
} from '../src/health/probe.js'
/** All-passing seams; each test overrides exactly one to prove sub-check independence. */
function healthySeams(over: Partial<HealthProbeSeams> = {}): HealthProbeSeams {
return {
isFrpcAlive: () => true,
probeBaseApp: async () => true,
readFrpcLog: () => 'proxy [web-terminal] start proxy success',
certNotAfter: () => new Date('2026-08-01T00:00:00Z'),
now: () => new Date('2026-07-08T00:00:00Z'),
...over,
}
}
describe('frpcProxyStarted (log scan)', () => {
it('detects the frpc start-proxy-success line', () => {
expect(frpcProxyStarted('2026/07/08 [I] [proxy_manager] start proxy success')).toBe(true)
})
it('is false before frpc reports success', () => {
expect(frpcProxyStarted('login to server success\nstart proxy ...')).toBe(false)
expect(frpcProxyStarted('')).toBe(false)
})
})
describe('certIsFresh (near-expiry check)', () => {
const now = new Date('2026-07-08T00:00:00Z')
it('is fresh when notAfter is beyond the renewal window', () => {
const notAfter = new Date(now.getTime() + DEFAULT_CERT_RENEW_WINDOW_MS + 60_000)
expect(certIsFresh(notAfter, now, DEFAULT_CERT_RENEW_WINDOW_MS)).toBe(true)
})
it('is NOT fresh when notAfter is inside the renewal window', () => {
const notAfter = new Date(now.getTime() + DEFAULT_CERT_RENEW_WINDOW_MS - 60_000)
expect(certIsFresh(notAfter, now, DEFAULT_CERT_RENEW_WINDOW_MS)).toBe(false)
})
it('treats a missing cert (null notAfter) as not fresh', () => {
expect(certIsFresh(null, now, DEFAULT_CERT_RENEW_WINDOW_MS)).toBe(false)
})
})
describe('probeLoopbackBaseApp (loopback-only)', () => {
it('targets 127.0.0.1:PORT and returns true on an ok response', async () => {
const fetchImpl = vi.fn(async (url: string) => ({ ok: url.includes('127.0.0.1:3000') }))
await expect(probeLoopbackBaseApp(3000, fetchImpl)).resolves.toBe(true)
expect(fetchImpl).toHaveBeenCalledWith('http://127.0.0.1:3000/')
})
it('returns false on a non-ok response', async () => {
await expect(probeLoopbackBaseApp(3000, async () => ({ ok: false }))).resolves.toBe(false)
})
it('swallows a rejected fetch (a probe never throws)', async () => {
await expect(
probeLoopbackBaseApp(3000, async () => {
throw new Error('ECONNREFUSED')
}),
).resolves.toBe(false)
})
it('rejects an out-of-range port without fetching', async () => {
const fetchImpl = vi.fn(async () => ({ ok: true }))
await expect(probeLoopbackBaseApp(0, fetchImpl)).resolves.toBe(false)
await expect(probeLoopbackBaseApp(70000, fetchImpl)).resolves.toBe(false)
expect(fetchImpl).not.toHaveBeenCalled()
})
})
describe('runHealthProbe (aggregate verdict)', () => {
it('is healthy when all four sub-checks pass', async () => {
const report = await runHealthProbe(healthySeams())
expect(report).toEqual<HealthReport>({
frpcAlive: true,
baseAppReachable: true,
proxyStarted: true,
certFresh: true,
healthy: true,
})
})
it('is unhealthy if frpc is dead', async () => {
const report = await runHealthProbe(healthySeams({ isFrpcAlive: () => false }))
expect(report.frpcAlive).toBe(false)
expect(report.healthy).toBe(false)
})
it('is unhealthy if the base app is unreachable', async () => {
const report = await runHealthProbe(healthySeams({ probeBaseApp: async () => false }))
expect(report.baseAppReachable).toBe(false)
expect(report.healthy).toBe(false)
})
it('is unhealthy if the proxy never started', async () => {
const report = await runHealthProbe(healthySeams({ readFrpcLog: () => 'connecting...' }))
expect(report.proxyStarted).toBe(false)
expect(report.healthy).toBe(false)
})
it('is unhealthy if the cert is near expiry', async () => {
const report = await runHealthProbe(
healthySeams({
certNotAfter: () => new Date('2026-07-08T01:00:00Z'), // 1h out, inside 8h window
}),
)
expect(report.certFresh).toBe(false)
expect(report.healthy).toBe(false)
})
})
describe('renderHealthStatus (INV9 — non-secret only)', () => {
const report: HealthReport = {
frpcAlive: true,
baseAppReachable: true,
proxyStarted: true,
certFresh: true,
healthy: true,
}
it('prints subdomain, host id, expiry date, and flags', () => {
const lines = renderHealthStatus(
{ subdomain: 'alice', hostId: 'h-1', certNotAfter: new Date('2026-08-01T00:00:00Z') },
report,
)
const joined = lines.join('\n')
expect(joined).toContain('subdomain: alice')
expect(joined).toContain('host_id: h-1')
expect(joined).toContain('cert_expiry: 2026-08-01T00:00:00.000Z')
expect(joined).toContain('healthy: true')
})
it('leaks NO key/cert/token/CSR material', () => {
const lines = renderHealthStatus(
{ subdomain: 'alice', hostId: 'h-1', certNotAfter: new Date('2026-08-01T00:00:00Z') },
report,
)
const joined = lines.join('\n')
expect(joined).not.toMatch(/PRIVATE KEY|BEGIN CERTIFICATE|BEGIN CERTIFICATE REQUEST/)
expect(joined.toLowerCase()).not.toMatch(/token|secret|csr|pem/)
})
it('renders (none)/(unknown) placeholders when identifiers are absent', () => {
const lines = renderHealthStatus({ subdomain: null, hostId: null, certNotAfter: null }, report)
const joined = lines.join('\n')
expect(joined).toContain('subdomain: (none)')
expect(joined).toContain('cert_expiry: (unknown)')
})
})
describe('startHealthMonitor (periodic)', () => {
function fakeTimer(): { timer: IntervalTimer; fire: () => void; cleared: boolean } {
let cb: (() => void) | null = null
const state = { cleared: false }
return {
timer: {
setInterval: (fn) => {
cb = fn
return 1
},
clearInterval: () => {
state.cleared = true
},
},
fire: () => cb?.(),
get cleared() {
return state.cleared
},
}
}
it('runs the probe on each tick and reports it', async () => {
const report: HealthReport = {
frpcAlive: true,
baseAppReachable: true,
proxyStarted: true,
certFresh: true,
healthy: true,
}
const probe = vi.fn(async () => report)
const seen: HealthReport[] = []
const ft = fakeTimer()
const monitor = startHealthMonitor(probe, (r) => seen.push(r), { timer: ft.timer })
ft.fire()
await Promise.resolve()
await Promise.resolve()
expect(probe).toHaveBeenCalledTimes(1)
expect(seen).toEqual([report])
monitor.stop()
expect(ft.cleared).toBe(true)
})
it('swallows a rejected probe (monitor never crashes)', async () => {
const ft = fakeTimer()
const onReport = vi.fn()
startHealthMonitor(async () => Promise.reject(new Error('boom')), onReport, { timer: ft.timer })
ft.fire()
await Promise.resolve()
await Promise.resolve()
expect(onReport).not.toHaveBeenCalled()
})
})

View File

@@ -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",

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,120 @@
/**
* A1 — ECDSA-P256 PKCS#10 parse + proof-of-possession, mirroring `ca/csr.ts` (Ed25519 relay path).
*
* Hardware-bound host/device keys are P-256 ONLY (Secure Enclave / Android Keystore). This module
* parses a P-256 PKCS#10 `CertificationRequest`, verifies its self-signature (PoP — the requester
* holds the private key for the embedded pubkey; `@peculiar`'s `req.verify()` handles ECDSA), and
* exposes the embedded public key as SubjectPublicKeyInfo DER so the issuer can gate on it.
*
* FAIL-CLOSED and UNIFORM: malformed DER, a non-P256 key, or a bad signature ALL return
* `{ ok: false, embeddedPubSpki: [] }` with no distinguishing detail. Independent of registry state.
*
* `reflect-metadata` must load before `@peculiar/x509` (tsyringe polyfill) — keep it first.
*/
import 'reflect-metadata'
import * as x509 from '@peculiar/x509'
import { AsnConvert } from '@peculiar/asn1-schema'
import { SubjectPublicKeyInfo } from '@peculiar/asn1-x509'
import { webcrypto } from 'node:crypto'
import { timingSafeEqualBytes } from '../util/bytes.js'
x509.cryptoProvider.set(webcrypto)
/** OID 1.2.840.10045.2.1 (id-ecPublicKey). */
const OID_EC_PUBLIC_KEY = '1.2.840.10045.2.1'
/** DER of the namedCurve parameter OID prime256v1 / secp256r1 (1.2.840.10045.3.1.7). */
const PRIME256V1_PARAM_DER = Uint8Array.from([
0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07,
])
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
}
/** True iff `spkiDer` is a well-formed EC P-256 SubjectPublicKeyInfo. Never throws. */
function isP256Spki(spkiDer: Uint8Array): boolean {
try {
const spki = AsnConvert.parse(toArrayBuffer(spkiDer), SubjectPublicKeyInfo)
if (spki.algorithm.algorithm !== OID_EC_PUBLIC_KEY) return false
const params = spki.algorithm.parameters
if (!params) return false
// Reuse the shared constant-time comparison rather than a local short-circuiting one.
return timingSafeEqualBytes(new Uint8Array(params), PRIME256V1_PARAM_DER)
} catch {
return false
}
}
/**
* Build a FRESH uniform-failure result per call — never share a module-level singleton (immutability):
* callers `toEqual`-assert this shape, and a shared instance would let one caller mutate a value another
* caller later reads.
*/
function uniformFailure(): { ok: false; embeddedPubSpki: Uint8Array } {
return { ok: false, embeddedPubSpki: new Uint8Array(0) }
}
/**
* Verify an ECDSA-P256 CSR's proof-of-possession. Parses the PKCS#10 DER, requires an EC P-256
* embedded key, and checks the self-signature. Returns the embedded pubkey as SPKI DER on success.
* FAIL-CLOSED and UNIFORM: any malformed input, non-P256 key, or bad signature yields
* `{ ok: false, embeddedPubSpki: [] }`. Independent of any registry state.
*/
export async function verifyCsrPoPEc(
csr: Uint8Array,
): Promise<{ ok: boolean; embeddedPubSpki: Uint8Array }> {
try {
const req = new x509.Pkcs10CertificateRequest(toArrayBuffer(csr))
const spkiDer = new Uint8Array(req.publicKey.rawData)
if (!isP256Spki(spkiDer)) return uniformFailure()
const ok = await req.verify()
return ok ? { ok: true, embeddedPubSpki: spkiDer } : uniformFailure()
} catch {
return uniformFailure()
}
}
/**
* Cheap routing predicate for the `/enroll` fork: true iff `csr` parses as a PKCS#10 whose embedded
* key is EC P-256. Inspects ONLY the SubjectPublicKeyInfo algorithm — it does NOT verify the
* self-signature (the native arm's `verifyCsrPoPEc` gate does that after routing). Never throws:
* a malformed CSR or any non-P256 key (e.g. Ed25519 relay CSRs) yields `false`, so the caller falls
* through to the Ed25519 relay arm.
*/
export function isEcP256Csr(csr: Uint8Array): boolean {
try {
const req = new x509.Pkcs10CertificateRequest(toArrayBuffer(csr))
return isP256Spki(new Uint8Array(req.publicKey.rawData))
} catch {
return false
}
}
/** A WebCrypto key pair (the `CryptoKeyPair` global is not in this project's TS lib set). */
type WebCryptoKeyPair = { readonly publicKey: CryptoKey; readonly privateKey: CryptoKey }
export interface BuildCsrEcResult {
/** The PKCS#10 CertificationRequest DER. */
readonly der: Uint8Array
/** The generated P-256 key pair (extractable — TEST ONLY). */
readonly keys: WebCryptoKeyPair
}
/**
* TEST HELPER — build a real ECDSA-P256 PKCS#10 CSR self-signed by a freshly generated P-256 key, so
* tests exercise the real parse/verify path (production CSRs come from hardware unchanged). Mirrors
* `ca/csr.ts` `buildCsr`. Returns the DER and the generated key pair.
*/
export async function buildCsrEc(subject = 'CN=web-terminal-device'): Promise<BuildCsrEcResult> {
const keys = (await webcrypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'verify'],
)) as unknown as WebCryptoKeyPair
const csr = await x509.Pkcs10CertificateRequestGenerator.create({
name: subject,
keys,
signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' },
})
return { der: new Uint8Array(csr.rawData), keys }
}

View File

@@ -0,0 +1,137 @@
/**
* A4 (FIX C-2) — P-256 DEVICE leaf issuance off the device-CA, via the single `assembleCertificate`
* primitive (KMS-signed TBS). This is the data-path identity the app presents on the existing mTLS
* path; it is a SEPARATE trust root from the Ed25519 relay agent-CA and the P-256 frp-client-CA
* (§1.3). Modeled on `ca/issue.ts` (`createRealLeafSigner`) but:
* - subject key is EC P-256 (Secure Enclave / Android Keystore are P-256 only);
* - the CA signs via `CaSigner.sign()` (device-CA hot signer, custody behind KMS — §5), sigAlg
* `ecdsa-p256`;
* - SAN = dNSName `<sub>.<baseDomain>` (the nginx :8470 enforcement key, FIX H-2) + URI
* `spiffe://relay.<trustDomain>/account/<a>/device/<d>` (identity/audit), built with relay-auth's
* OWN `spiffeIdFor(..., 'device')` so the SAN can never drift from the verifier's parser.
*
* Registry-gated like `assertLeafGate` (INV14): CSR proof-of-possession + device bound/active/
* non-revoked + CSR-key == registered-key. Any failure rejects UNIFORMLY (`DeviceLeafSignError`),
* never leaking which check failed; issuance is unreachable when any check fails. Serial + validity
* are read from the gated record (the record is authoritative for expiry/CRL pairing).
*
* `reflect-metadata` must load before `@peculiar/x509` (tsyringe polyfill) — keep it first.
*/
import 'reflect-metadata'
import * as x509 from '@peculiar/x509'
import { webcrypto } from 'node:crypto'
import { spiffeIdFor } from 'relay-auth/src/agent/spiffe.js'
import type { CaSigner } from '../boot/ca-wiring.js'
import type { DeviceStore } from '../store/ports.js'
import type { DeviceRecord } from '../registry/devices.js'
import { assembleCertificate } from './x509-assembler.js'
import { verifyCsrPoPEc } from './csr-ec.js'
import { timingSafeEqualBytes } from '../util/bytes.js'
x509.cryptoProvider.set(webcrypto)
/** Backdate notBefore to tolerate small clock skew between control-plane and the mTLS terminator. */
const CLOCK_SKEW_SEC = 60
/** Uniform device-leaf gate reject — never leaks which check failed (mirrors `LeafSignError`). */
export class DeviceLeafSignError extends Error {
constructor(public readonly code: 'csr_rejected' | 'not_registered') {
super('device leaf signing refused') // uniform message
this.name = 'DeviceLeafSignError'
}
}
export interface DeviceLeafResult {
readonly cert: Uint8Array
readonly caChain: readonly Uint8Array[]
readonly serial: string
readonly notBefore: Date
readonly notAfter: Date
}
export interface DeviceLeafSigner {
/** Gate on the device registry, then issue the P-256 leaf for the gated record. */
signDeviceLeaf(deviceId: string, csr: Uint8Array): Promise<DeviceLeafResult>
}
export interface DeviceLeafSignerDeps {
/** Device-CA P-256 signer (KMS-shaped; `inProcessP256CaSigner` in dev/tests). */
readonly signer: CaSigner
/** Device-CA subject as an `x509.Name`/DN string — used verbatim as the leaf issuer. */
readonly issuer: x509.Name | string
/** DER of [device-CA, root] returned to the app as its CA bundle. */
readonly caChainDer: readonly Uint8Array[]
/** Base domain for the dNSName SAN: `<sub>.<sanBaseDomain>` (e.g. `terminal.yaojia.wang`). */
readonly sanBaseDomain: string
/** Bare trust domain; the SPIFFE builder prepends `relay.`. */
readonly trustDomain: string
/** Device registry (gate source of truth). */
readonly devices: DeviceStore
}
/** Parse a hex serial into big-endian bytes for the certificate serialNumber. */
function hexToBytes(hex: string): Uint8Array {
return new Uint8Array(Buffer.from(hex, 'hex'))
}
/**
* The registry + proof-of-possession gate for a device leaf (INV14). Ordered checks, SAME reject:
* 1. CSR PoP — valid P-256 PKCS#10 self-signature over its embedded key.
* 2. device bound + active (non-revoked).
* 3. CSR-embedded key == the device's REGISTERED public key (no substitution).
* Returns the gated record + the embedded SPKI so the issuer builds the SAN from authoritative data.
*/
export async function assertDeviceLeafGate(
devices: DeviceStore,
deviceId: string,
csr: Uint8Array,
): Promise<{ record: DeviceRecord; embeddedPubSpki: Uint8Array }> {
const pop = await verifyCsrPoPEc(csr)
if (!pop.ok) throw new DeviceLeafSignError('csr_rejected')
const record = await devices.get(deviceId)
if (record === null || record.status === 'revoked') throw new DeviceLeafSignError('not_registered')
if (!timingSafeEqualBytes(record.ecPubkeySpki, pop.embeddedPubSpki)) {
throw new DeviceLeafSignError('not_registered')
}
return { record, embeddedPubSpki: pop.embeddedPubSpki }
}
/**
* Build the device-leaf signer. Each leaf: X.509 v3, EC P-256 subject = the CSR-embedded key,
* SAN dNSName `<sub>.<baseDomain>` + device SPIFFE URI, CA:false, KeyUsage digitalSignature, EKU
* clientAuth, validity [now-skew, record.notAfter], serial = record.serial, signed by the device-CA.
*/
export function createDeviceLeafSigner(deps: DeviceLeafSignerDeps): DeviceLeafSigner {
return {
async signDeviceLeaf(deviceId, csr) {
const { record, embeddedPubSpki } = await assertDeviceLeafGate(deps.devices, deviceId, csr)
const dnsName = `${record.subdomainScope}.${deps.sanBaseDomain}`
const spiffe = spiffeIdFor(record.accountId, record.deviceId, deps.trustDomain, 'device')
const notBefore = new Date(Date.now() - CLOCK_SKEW_SEC * 1000)
const notAfter = new Date(record.notAfter)
const cert = await assembleCertificate({
subjectPublicKey: embeddedPubSpki, // SPKI DER of the hardware-bound P-256 key
subject: `CN=${record.deviceId}`,
issuer: deps.issuer,
serialNumber: hexToBytes(record.serial),
notBefore,
notAfter,
extensions: [
new x509.SubjectAlternativeNameExtension([
{ type: 'dns', value: dnsName },
{ type: 'url', value: spiffe },
]),
new x509.BasicConstraintsExtension(false, undefined, true),
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.clientAuth]),
],
signer: deps.signer,
sigAlg: 'ecdsa-p256',
})
return { cert, caChain: deps.caChainDer, serial: record.serial, notBefore, notAfter }
},
}
}

View File

@@ -0,0 +1,117 @@
/**
* B1 / FIX H-host-2, H-host-4 — the P-256 HOST frp-client leaf signer.
*
* The `frp-client-CA` is P-256 (matching the VPS `gen-device-ca.sh` "P-256 never Ed25519" and the
* frps Go-TLS client-cert requirement), so the host key, its CSR, and this leaf are ALL P-256 —
* distinct from the Ed25519 relay agent-CA. This module mirrors `ca/issue.ts`'s
* `createRealLeafSigner` SHAPE (registry-gate → build extensions → sign → return leaf + CA chain)
* but:
* (a) verifies the P-256 CSR via `verifyCsrPoPEc` (NOT the Ed25519 `verifyCsrPoP`);
* (b) gates on the host registry (bound, active, non-revoked, embedded pubkey == registered
* pubkey — the pubkey is an EC SubjectPublicKeyInfo DER here, not a raw-32 Ed25519 key);
* (c) issues via `assembleCertificate` (A1) with `sigAlg:'ecdsa-p256'` and the frp-client-CA
* `CaSigner` — raw CA key never loaded (INV9).
*
* SAN grammar (ONE grammar, FIX H-host-4): `dNSName <sub>.<dnsZone>` is the ENFORCEMENT key the
* A3 nginx njs parses; the `URI` SPIFFE-ID (`.../host/<sub>`, built with relay-auth's own builder so
* it can never drift from the verifier's parser) is identity/audit. EKU clientAuth, CA:false,
* KeyUsage digitalSignature; validity `[now-CLOCK_SKEW, now+ttl]`.
*
* `reflect-metadata` must load before `@peculiar/x509` (tsyringe polyfill) — keep it first.
*/
import 'reflect-metadata'
import * as x509 from '@peculiar/x509'
import { webcrypto, randomBytes } from 'node:crypto'
import { spiffeIdFor } from 'relay-auth/src/agent/spiffe.js'
import type { HostStore } from '../store/ports.js'
import type { HostRecord } from '../model/records.js'
import type { CaSigner } from '../boot/ca-wiring.js'
import { assembleCertificate } from './x509-assembler.js'
import { verifyCsrPoPEc } from './csr-ec.js'
import { LeafSignError, DEFAULT_LEAF_TTL_SEC, type LeafSigner } from './sign.js'
import { timingSafeEqualBytes } from '../util/bytes.js'
x509.cryptoProvider.set(webcrypto)
/** Backdate notBefore to tolerate small clock skew between control-plane, host, frps and nginx. */
const CLOCK_SKEW_SEC = 60
/** DNS zone the reachable subdomain lives under; the leftmost label is the nginx enforcement key. */
const DEFAULT_DNS_ZONE = 'terminal.yaojia.wang'
export interface FrpClientLeafSignerDeps {
readonly hosts: HostStore
/** frp-client-CA P-256 signer (KMS-shaped; `inProcessP256CaSigner` in tests). Raw key never loaded. */
readonly signer: CaSigner
/** frp-client-CA subject as a `Name` — used verbatim as the leaf issuer so `checkIssued` matches. */
readonly issuerName: x509.Name | string
/** DER of [frp-client-CA] (+ any parents) returned to the host as its CA bundle. */
readonly caChainDer: readonly Uint8Array[]
/** Bare trust domain for the SPIFFE-ID; `spiffeIdFor` prepends `relay.`. */
readonly trustDomain: string
/** DNS zone for the enforcement `dNSName` (default `terminal.yaojia.wang`). */
readonly dnsZone?: string
readonly leafTtlSec?: number
}
/**
* P-256 host-leaf gate — the ECDSA analogue of `sign.ts` `assertLeafGate`. SAME ordered checks,
* SAME uniform `LeafSignError` reject (never leaks which check failed), issuance NEVER reached on
* any failure:
* 1. P-256 CSR proof-of-possession (`verifyCsrPoPEc`, independent of registry state);
* 2. no substitution: the CSR's embedded EC SPKI == the presented `agentPubkey`;
* 3. registry: host bound, ACTIVE (non-revoked), and its stored pubkey == the presented one.
*/
async function assertFrpClientLeafGate(
hosts: HostStore,
hostId: string,
agentPubkey: Uint8Array,
csr: Uint8Array,
): Promise<HostRecord> {
const pop = await verifyCsrPoPEc(csr)
if (!pop.ok) throw new LeafSignError('csr_rejected')
if (!timingSafeEqualBytes(pop.embeddedPubSpki, agentPubkey)) throw new LeafSignError('csr_rejected')
const host = await hosts.get(hostId)
if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered')
if (!timingSafeEqualBytes(host.agentPubkey, agentPubkey)) throw new LeafSignError('not_registered')
return host
}
/**
* Build the production HOST frp-client leaf signer. Every issued leaf: X.509 v3, EC P-256 subject =
* the enrolled host pubkey (SPKI DER), SAN = { dNSName `<sub>.<zone>`, URI host-SPIFFE-ID }, CA:false,
* KeyUsage digitalSignature, EKU clientAuth, validity `[now-skew, now+ttl]`, signed by the P-256
* frp-client-CA via `assembleCertificate`. Returns leaf DER + the injected CA chain DER.
*/
export function createFrpClientLeafSigner(deps: FrpClientLeafSignerDeps): LeafSigner {
const ttl = deps.leafTtlSec ?? DEFAULT_LEAF_TTL_SEC
const dnsZone = deps.dnsZone ?? DEFAULT_DNS_ZONE
return {
async signHostLeaf(hostId, agentPubkey, csr) {
const host = await assertFrpClientLeafGate(deps.hosts, hostId, agentPubkey, csr)
const sub = host.subdomain
const dnsName = `${sub}.${dnsZone}`
const spiffe = spiffeIdFor(host.accountId, sub, deps.trustDomain, 'host')
const now = Date.now()
const cert = await assembleCertificate({
subjectPublicKey: agentPubkey, // EC P-256 SubjectPublicKeyInfo DER
subject: `CN=${sub}`,
issuer: deps.issuerName,
serialNumber: randomBytes(16),
notBefore: new Date(now - CLOCK_SKEW_SEC * 1000),
notAfter: new Date(now + ttl * 1000),
extensions: [
new x509.SubjectAlternativeNameExtension([
{ type: 'dns', value: dnsName }, // FIX H-host-4: the enforcement key nginx njs parses
{ type: 'url', value: spiffe }, // identity/audit — never the enforcement key
]),
new x509.BasicConstraintsExtension(false, undefined, true),
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.clientAuth]),
],
signer: deps.signer,
sigAlg: 'ecdsa-p256',
})
return { cert, caChain: deps.caChainDer }
},
}
}

View File

@@ -1,55 +1,95 @@
/**
* T15 — CA leaf RENEWAL (INV14). Re-signs a short-TTL leaf for an already-bound, non-revoked host
* under the current intermediate. SAME guards as T8 `signHostLeaf`: (1) CSR proof-of-possession
* against the embedded pubkey; (2) embedded pubkey == the host's registered `agent_pubkey`;
* (3) host active/non-revoked. Any failure → same reject path. A drained/revoked host cannot renew
* (closes the INV12+INV14 loop). Signing = `CaSigner.sign()` (KMS, §3.1), never a raw key.
* Distinct file from T8 (`ca/sign.ts`) — shares only the KMS-signer primitive.
* A6 (FIX H-host-3) — leaf RENEWAL, UPGRADED off the DEV JSON-blob placeholder to REAL X.509.
*
* Renewal == re-issue a fresh short-TTL leaf for an ALREADY-bound, non-revoked identity using the
* SAME registered key + the SAME subdomain the registry holds — NEVER client-supplied input (the
* A4 anti-smuggling lesson). It does NOT re-implement issuance: it DELEGATES to the P-256 issuers
* (`frpclient-issue` for the host frp-client leaf, `device-issue` for the device leaf), both of which
* route through the single `assembleCertificate` primitive (sigAlg `ecdsa-p256`, the CA signer behind
* KMS, INV9). Delegation keeps ONE registry-gate + ONE SAN grammar (DRY) and means the emitted cert is
* a real, tool-parseable X.509 v3 leaf — not the retired signed-JSON placeholder.
*
* The gate work all lives in the delegated signers:
* - host: `signHostLeaf(hostId, subjectPubkey, csr)` verifies CSR PoP, that the CSR's embedded key
* == `subjectPubkey` (the SAME-KEY check — the route passes the CURRENT cert's key), and
* that the registry host is active with a matching key; it stamps `host.subdomain`.
* - device: `signDeviceLeaf(deviceId, csr)` verifies CSR PoP, the device is active, and the CSR key
* == the registered key; it stamps `record.subdomainScope`.
* Renewal adds the leaf's `notAfter`, read back from the emitted cert (byte-exact with the DER) for
* BOTH paths so the `/renew` routes can echo it. It ALSO extends device validity: the host signer
* recomputes `now()+ttl` on every call, but the device signer stamps the record's `notAfter` (set once
* at enroll), so device renewal first bumps that record via the `DeviceRegistry` — otherwise every
* device renewal would reproduce the original enrollment expiry.
*
* `reflect-metadata` must load before `@peculiar/x509` (tsyringe polyfill) — keep the side-effect first.
*/
import type { HostStore } from '../store/ports.js'
import type { CaSigner } from '../boot/ca-wiring.js'
import { verifyCsrPoP } from './csr.js'
import { LeafSignError } from './sign.js'
import { timingSafeEqualBytes, bytesToBase64 } from '../util/bytes.js'
import 'reflect-metadata'
import * as x509 from '@peculiar/x509'
import { webcrypto } from 'node:crypto'
import type { LeafSigner } from './sign.js'
import type { DeviceLeafSigner } from './device-issue.js'
import type { DeviceExpiryRenewer } from '../registry/devices.js'
x509.cryptoProvider.set(webcrypto)
/** A renewed leaf: the re-issued DER, its CA chain, and the parsed expiry `/renew` echoes back. */
export interface RenewedLeaf {
readonly cert: Uint8Array
readonly caChain: readonly Uint8Array[]
readonly notAfter: Date
}
export interface LeafRenewerDeps {
readonly hosts: HostStore
readonly signer: CaSigner
readonly caChainDer: readonly Uint8Array[]
readonly leafTtlSec?: number
/** Host frp-client P-256 issuer (`frpclient-issue`). Routes through `assembleCertificate`. */
readonly hostSigner: LeafSigner
/** Device P-256 issuer (`device-issue`). Routes through `assembleCertificate`. */
readonly deviceSigner: DeviceLeafSigner
/**
* Device leaf-expiry renewer (the `DeviceRegistry`). MUST share the SAME `DeviceStore` as
* `deviceSigner` so the bumped expiry is visible to the signer's gate. The host path recomputes
* `now()+ttl` inside its signer; the device signer reads `record.notAfter`, so device renewal must
* bump that record FIRST or every renewal reproduces the original enrollment expiry.
*/
readonly deviceExpiry: DeviceExpiryRenewer
}
export interface LeafRenewer {
renewHostLeaf(
hostId: string,
csr: Uint8Array,
): Promise<{ cert: Uint8Array; caChain: readonly Uint8Array[] }>
/**
* Re-issue the host frp-client leaf. `subjectPubkey` is the CURRENT cert's SubjectPublicKeyInfo DER
* (supplied by the mTLS-authenticated `/renew` route); passing it makes the delegated gate enforce
* BOTH the SAME-KEY rule (CSR key == current cert key) AND registry consistency in one check. The
* re-issued leaf's subdomain/SPIFFE come from the registry record — never from `csr` or the caller.
*/
renewHostLeaf(hostId: string, subjectPubkey: Uint8Array, csr: Uint8Array): Promise<RenewedLeaf>
/**
* Re-issue the device leaf. The delegated gate enforces CSR PoP + active + CSR key == registered key;
* the SAN is stamped from `record.subdomainScope` (SAME scope — no smuggling).
*/
renewDeviceLeaf(deviceId: string, csr: Uint8Array): Promise<RenewedLeaf>
}
const DEFAULT_LEAF_TTL_SEC = 24 * 60 * 60
export function createLeafRenewer(deps: LeafRenewerDeps): LeafRenewer {
const ttl = deps.leafTtlSec ?? DEFAULT_LEAF_TTL_SEC
return {
async renewHostLeaf(hostId, csr) {
const host = await deps.hosts.get(hostId)
// A revoked/absent host cannot renew (INV12 + INV14).
if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered')
const pop = await verifyCsrPoP(csr)
if (!pop.ok) throw new LeafSignError('csr_rejected')
// embedded pubkey must equal the host's REGISTERED pubkey (no key substitution on renewal).
if (!timingSafeEqualBytes(pop.embeddedPub, host.agentPubkey)) throw new LeafSignError('csr_rejected')
const notAfter = Math.floor(Date.now() / 1000) + ttl
const tbs = new TextEncoder().encode(
JSON.stringify({ v: 1, hostId, subjectSpki: bytesToBase64(host.agentPubkey), notAfter, renewed: true }),
)
const sig = await deps.signer.sign(tbs) // KMS sign(); no raw key
const cert = new TextEncoder().encode(
JSON.stringify({ tbs: bytesToBase64(tbs), sig: bytesToBase64(sig) }),
)
return { cert, caChain: deps.caChainDer }
async renewHostLeaf(hostId, subjectPubkey, csr) {
// Delegated gate (frpclient-issue) rejects UNIFORMLY (LeafSignError) on any failure; issuance is
// never reached. The re-issued leaf stamps host.subdomain from the registry (anti-smuggling).
const { cert, caChain } = await deps.hostSigner.signHostLeaf(hostId, subjectPubkey, csr)
const notAfter = new x509.X509Certificate(cert).notAfter // authoritative expiry, read from the cert
return { cert, caChain, notAfter }
},
async renewDeviceLeaf(deviceId, csr) {
// EXTEND validity FIRST: the device signer stamps `record.notAfter`, which is set ONCE at enroll,
// so without this bump every renewal reproduces the original enrollment expiry (and eventually
// issues already-expired certs). The registry recomputes + persists `now()+ttl` for an active
// device, keeping the record (CRL/expiry pairing source of truth) consistent with the emitted
// cert; an unknown/revoked device is left untouched and the delegated gate below rejects it.
await deps.deviceExpiry.renewDeviceExpiry(deviceId)
// Delegated gate (device-issue) rejects UNIFORMLY (DeviceLeafSignError) on any failure.
const leaf = await deps.deviceSigner.signDeviceLeaf(deviceId, csr)
// Defense in depth: surface the expiry actually embedded in the DER (byte-exact with the cert),
// exactly as `renewHostLeaf` does — never a value that could drift from what was signed.
const notAfter = new x509.X509Certificate(leaf.cert).notAfter
return { cert: leaf.cert, caChain: leaf.caChain, notAfter }
},
}
}

View File

@@ -0,0 +1,177 @@
/**
* A1 / FIX C-1 — the SINGLE X.509 issuance primitive for the native-tunnel PKI.
*
* WHY THIS EXISTS: `@peculiar/x509`'s `X509CertificateGenerator.create` needs a `signingKey:
* CryptoKey` and CANNOT delegate to an async KMS. Real-X.509 AND KMS-non-exportable signing are
* therefore mutually exclusive as built. This module resolves that: it DER-encodes the v3
* `TBSCertificate` itself (reusing the battle-tested `@peculiar/asn1-x509` schemas — no hand-rolled
* full-certificate DER) and signs the SERIALIZED TBS by calling `CaSigner.sign(tbsDer)` — the KMS
* boundary. The raw CA private key is NEVER loaded in this module (INV9). All future issuers (host
* frp-client, device, renew, CRL) route through this one primitive.
*
* `reflect-metadata` must load before `@peculiar/asn1-schema`/`@peculiar/x509` (tsyringe polyfill) —
* keep the side-effect import first.
*/
import 'reflect-metadata'
import * as x509 from '@peculiar/x509'
import { AsnConvert } from '@peculiar/asn1-schema'
import {
TBSCertificate,
Certificate,
AlgorithmIdentifier,
Name,
Validity,
SubjectPublicKeyInfo,
Extension,
Extensions,
Version,
} from '@peculiar/asn1-x509'
import { ECDSASigValue } from '@peculiar/asn1-ecc'
import { webcrypto } from 'node:crypto'
import type { CaSigner } from '../boot/ca-wiring.js'
x509.cryptoProvider.set(webcrypto)
/** Signature-algorithm family the CA signs with. Drives OID + signatureValue encoding. */
export type SigAlgFamily = 'ed25519' | 'ecdsa-p256'
/** OID 1.3.101.112 (Ed25519); no algorithm parameters. */
const OID_ED25519 = '1.3.101.112'
/** OID 1.2.840.10045.4.3.2 (ecdsa-with-SHA256); no algorithm parameters. */
const OID_ECDSA_WITH_SHA256 = '1.2.840.10045.4.3.2'
/** Raw P1363 (r||s) length for a P-256 signature — the WebCrypto/hardware-native ECDSA shape. */
const P256_P1363_LEN = 64
/** Raw Ed25519 signature length — the fixed 64 bytes embedded verbatim in the signatureValue BIT STRING. */
const ED25519_SIG_LEN = 64
export interface AssembleCertificateInput {
/** Subject public key: a WebCrypto public `CryptoKey` OR its SubjectPublicKeyInfo DER. */
readonly subjectPublicKey: CryptoKey | Uint8Array
/** Subject Distinguished Name (an `x509.Name` or a DN string like `CN=alice`). */
readonly subject: x509.Name | string
/** Issuer Distinguished Name — MUST equal the signing CA's subject so `checkIssued` matches. */
readonly issuer: x509.Name | string
/** Positive serial-number bytes (big-endian). Sign/leading-zero normalized internally. */
readonly serialNumber: Uint8Array
readonly notBefore: Date
readonly notAfter: Date
/** Pre-built extensions (SAN, BasicConstraints, KeyUsage, EKU, …) as `@peculiar/x509` objects. */
readonly extensions: readonly x509.Extension[]
/** KMS-shaped CA signer. `sign(tbsDer)` is the only crypto touchpoint; raw key never loaded. */
readonly signer: CaSigner
/** Declared signature-algorithm family — MUST match what `signer.sign` produces. */
readonly sigAlg: SigAlgFamily
}
/** Copy a `Uint8Array` view into a standalone `ArrayBuffer` (never a `SharedArrayBuffer`). */
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
}
/**
* Normalize big-endian bytes into the content octets of a DER positive INTEGER: strip leading zero
* bytes (keeping at least one), then prepend `0x00` if the high bit is set so the value can never be
* read as negative. The asn1 INTEGER converter uses these bytes verbatim, so this MUST run first.
*/
function toPositiveIntegerBytes(raw: Uint8Array): Uint8Array {
if (raw.length === 0) throw new Error('integer bytes must be non-empty')
let start = 0
while (start < raw.length - 1 && raw[start] === 0x00) start++
const trimmed = raw.subarray(start)
if ((trimmed[0]! & 0x80) === 0) return trimmed
const prefixed = new Uint8Array(trimmed.length + 1)
prefixed.set(trimmed, 1)
return prefixed
}
/**
* Normalize an ECDSA signature to the DER `ECDSA-Sig-Value ::= SEQUENCE { INTEGER r, INTEGER s }`
* the X.509 signatureValue BIT STRING requires. Accepts BOTH shapes a signer may return:
* - raw P1363 `r || s` (64 bytes for P-256, the WebCrypto / Secure-Enclave / StrongBox shape) →
* split and re-encode each half as a positive INTEGER;
* - already-DER `ECDSA-Sig-Value` (Node `crypto.sign` default) → validated and passed through.
* Throws on anything that is neither, so a malformed signer surfaces at issuance, not on the wire.
*/
export function normalizeEcdsaSignatureToDer(sig: Uint8Array): Uint8Array {
if (sig.length === P256_P1363_LEN) {
const half = sig.length / 2
const r = toPositiveIntegerBytes(sig.subarray(0, half))
const s = toPositiveIntegerBytes(sig.subarray(half))
const value = new ECDSASigValue({ r: toArrayBuffer(r), s: toArrayBuffer(s) })
return new Uint8Array(AsnConvert.serialize(value))
}
// Not raw P1363 — require a valid DER ECDSA-Sig-Value, else reject (fail loud at issuance).
try {
AsnConvert.parse(toArrayBuffer(sig), ECDSASigValue)
return sig
} catch {
throw new Error('ECDSA signature is neither raw P1363 (64 bytes) nor DER ECDSA-Sig-Value')
}
}
/** Resolve a subject key (CryptoKey or SPKI DER) to an asn1 `SubjectPublicKeyInfo`. */
async function toSpki(key: CryptoKey | Uint8Array): Promise<SubjectPublicKeyInfo> {
const der = key instanceof Uint8Array ? key : new Uint8Array(await webcrypto.subtle.exportKey('spki', key))
return AsnConvert.parse(toArrayBuffer(der), SubjectPublicKeyInfo)
}
/** Convert an `x509.Name` or DN string to an asn1 `Name` via its canonical DER. */
function toAsnName(name: x509.Name | string): Name {
const der = name instanceof x509.Name ? name.toArrayBuffer() : new x509.Name(name).toArrayBuffer()
return AsnConvert.parse(der, Name)
}
/** The OID for a signature family; identical instance-value in tbs.signature and outer sigAlg. */
function algorithmOid(sigAlg: SigAlgFamily): string {
return sigAlg === 'ed25519' ? OID_ED25519 : OID_ECDSA_WITH_SHA256
}
/** Encode the raw CA-signer output into the certificate signatureValue for the declared family. */
function encodeSignatureValue(sigAlg: SigAlgFamily, rawSig: Uint8Array): Uint8Array {
// Ed25519: the raw 64-byte signature goes directly into the BIT STRING. Validate the length first —
// a signer that returns anything but exactly 64 bytes (truncated/malformed) would otherwise embed a
// structurally-invalid signature; fail loud at issuance rather than emit an unverifiable cert.
if (sigAlg === 'ed25519') {
if (rawSig.length !== ED25519_SIG_LEN) {
throw new Error(
`Ed25519 signature must be exactly ${ED25519_SIG_LEN} bytes, got ${rawSig.length}`,
)
}
return rawSig
}
return normalizeEcdsaSignatureToDer(rawSig)
}
/**
* Assemble a signed X.509 v3 leaf certificate DER. Builds the `TBSCertificate`, serializes it,
* signs the SERIALIZED TBS via `signer.sign` (KMS boundary — raw key never loaded), then wraps it as
* `Certificate { tbsCertificate, signatureAlgorithm, signatureValue }`. The SAME `AlgorithmIdentifier`
* OID is set in BOTH `tbsCertificate.signature` and `certificate.signatureAlgorithm` (X.509 requires
* them identical). The exact signed TBS bytes are embedded verbatim (`tbsCertificateRaw`), so the
* emitted certificate's signature always covers precisely what was signed.
*/
export async function assembleCertificate(input: AssembleCertificateInput): Promise<Uint8Array> {
const oid = algorithmOid(input.sigAlg)
const tbs = new TBSCertificate({
version: Version.v3,
serialNumber: toArrayBuffer(toPositiveIntegerBytes(input.serialNumber)),
signature: new AlgorithmIdentifier({ algorithm: oid }),
issuer: toAsnName(input.issuer),
validity: new Validity({ notBefore: input.notBefore, notAfter: input.notAfter }),
subject: toAsnName(input.subject),
subjectPublicKeyInfo: await toSpki(input.subjectPublicKey),
extensions: new Extensions(input.extensions.map((e) => AsnConvert.parse(e.rawData, Extension))),
})
const tbsDer = new Uint8Array(AsnConvert.serialize(tbs))
const rawSig = await input.signer.sign(tbsDer)
const signatureValue = encodeSignatureValue(input.sigAlg, rawSig)
const certificate = new Certificate({
tbsCertificate: tbs,
tbsCertificateRaw: toArrayBuffer(tbsDer), // embed the EXACT bytes that were signed
signatureAlgorithm: new AlgorithmIdentifier({ algorithm: oid }),
signatureValue: toArrayBuffer(signatureValue),
})
return new Uint8Array(AsnConvert.serialize(certificate))
}

View File

@@ -23,6 +23,7 @@ import { createSessionRegistry } from './registry/sessions.js'
import { createSubdomainAssigner } from './subdomain/assign.js'
import { createPairingIssuer } from './pairing/issue.js'
import { createPairingRedeemer } from './pairing/redeem.js'
import { createNativeHostEnroller } from './pairing/native-redeem.js'
import { createLeafSigner, type LeafSigner } from './ca/sign.js'
import { loadRealLeafSigner } from './ca/issue.js'
import { createRoutingTable } from './routing/table.js'
@@ -33,6 +34,17 @@ import { createAuthorizer, type CapabilityVerifier } from './api/authz.js'
import { buildRouter } from './api/provision.js'
import { buildCaSigner, inProcessCaSigner, type KmsResolver, type CaSigner } from './boot/ca-wiring.js'
import { configureCapabilityVerifyKey } from './boot/verifier.js'
import {
createDeviceRegistry,
createMemoryDeviceStore,
type DeviceRegistry,
} from './registry/devices.js'
import { createFrpClientLeafSigner } from './ca/frpclient-issue.js'
import { createDeviceLeafSigner, type DeviceLeafSigner } from './ca/device-issue.js'
import { createLeafRenewer } from './ca/rotate.js'
import { buildDeviceEnrollRouter, type SubdomainOwnershipResolver } from './api/device-enroll.js'
import { buildRenewRouter } from './api/renew.js'
import { buildNativeCas, DEFAULT_NATIVE_DNS_ZONE, type NativeCas, type NativeCaMaterial } from './boot/native-ca.js'
import type { RevocationBus } from 'relay-contracts'
export interface ControlPlaneOverrides {
@@ -41,6 +53,29 @@ export interface ControlPlaneOverrides {
readonly bus?: RevocationBus & Partial<TestableRevocationBus>
readonly kmsResolver?: KmsResolver
readonly caChainDer?: readonly Uint8Array[]
/** Production mode → native-CA + renew-anchor material is fail-closed (INV9). Defaults to NODE_ENV. */
readonly production?: boolean
/** Production-loaded native-tunnel CA material (per-CA KMS ref + public cert DER). */
readonly nativeCaMaterial?: NativeCaMaterial
/** DNS zone the native-tunnel leaves are stamped under (defaults to `terminal.yaojia.wang`). */
readonly nativeDnsZone?: string
}
/** Native-tunnel PKI handles exposed for wiring + tests (the enroll/renew issuers behind the routes). */
export interface NativeTunnelHandles {
readonly nativeCas: NativeCas
/** Host frp-client P-256 leaf signer (used at onboarding to mint the first leaf). */
readonly hostSigner: LeafSigner
/** Device P-256 leaf signer (shares the device store with the registry + renew path). */
readonly deviceSigner: DeviceLeafSigner
/** Device registry (ownership + cap/rate + expiry-renewal source of truth). */
readonly deviceRegistry: DeviceRegistry
}
export interface BuiltControlPlane {
readonly app: FastifyInstance
readonly stores: Stores
readonly nativeTunnel: NativeTunnelHandles
}
/**
@@ -97,7 +132,7 @@ function devKmsResolver(): KmsResolver {
export async function buildControlPlane(
env: ControlPlaneEnv,
overrides: ControlPlaneOverrides = {},
): Promise<{ app: FastifyInstance; stores: Stores }> {
): Promise<BuiltControlPlane> {
const stores = overrides.stores ?? createMemoryStores()
const bus: RevocationBus = overrides.bus ?? createInMemoryRevocationBus()
const caChainDer = overrides.caChainDer ?? []
@@ -142,7 +177,81 @@ export async function buildControlPlane(
expectedAud: env.baseDomain,
})
const app = Fastify({ logger: false })
await app.register(buildRouter({ authorizer, accounts, hosts, pairingIssuer, redeemer, deprovisioner }))
return { app, stores }
// ── 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, nativeEnroller }))
// Device enrollment is bearer-gated by the SAME capability verifier seam the admin API uses.
await app.register(buildDeviceEnrollRouter({ verifier, devices: deviceRegistry, signer: deviceSigner, ownership }))
// Leaf renewal is mTLS-authenticated (current client cert) — anchors chain-validate the presented cert.
await app.register(
buildRenewRouter({
hosts,
devices: deviceRegistry,
renewer,
hostCaAnchorsDer: nativeCas.frpClientCa.anchorsDer,
deviceCaAnchorsDer: nativeCas.deviceCa.anchorsDer,
}),
)
return { app, stores, nativeTunnel: { nativeCas, hostSigner, deviceSigner, deviceRegistry } }
}

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

View File

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

View 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 3060s 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)
})
})

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

View File

@@ -10,6 +10,32 @@
#
# nginx 1.24: there is NO standalone `http2` directive here and NO `http2 off;` — HTTP/2 is off by
# default on this listen and `http2 off;` is INVALID on 1.24 (would fail `nginx -t`, PLAN R7).
#
# A3 / FIX C-native-3 — cert→Host BINDING (the SINGLE load-bearing tenant-isolation control). On top
# of `ssl_verify_client on` (which only proves the cert is device-CA-signed), njs parses the leaf's
# dNSName SAN and a `map` requires its leftmost label == the leftmost label of the requested SNI,
# `return 403` otherwise. A cert stamped `alice.terminal.yaojia.wang` may ONLY reach `alice.*`; a
# tenant-A cert presented to tenant-B's host is denied. Rule lives in the `map` (never scattered
# `if`s); exactly one `if` guards the single `return 403`.
#
# PREREQ (VPS main nginx.conf, MAIN context — NOT here): `load_module modules/ngx_http_js_module.so;`
# (Debian nginx: `apt-get install nginx-module-njs`). Install the module file to /etc/nginx/njs/
# getCertSub.js so the relative `js_import` below resolves against the /etc/nginx prefix.
js_import certsub from njs/getCertSub.js;
js_set $cert_sub certsub.getCertSub; # leftmost dNSName SAN label of the verified client cert ('' on any parse failure)
# Allow iff the requested SNI is EXACTLY `<cert_sub>.terminal.yaojia.wang` — the cert's leftmost
# dNSName label equals the requested subdomain, anchored to the FULL zone. Anchoring the zone here
# (not just `<label>.`) makes this map SELF-SUFFICIENT: it does not depend on the `server_name` regex
# (line ~46) or the stream SNI routing to constrain the zone — belt-and-suspenders for the one control.
# `(?P=s)` is the PCRE named backreference to `s`. An empty $cert_sub (fail-closed njs output) can
# never satisfy `[^:]+`, so it always falls through to `default 0` → denied. Case-sensitive (~):
# our subdomains and stamped SANs are lowercase.
map "$cert_sub:$ssl_server_name" $cert_host_ok {
"~^(?<s>[^:]+):(?P=s)\.terminal\.yaojia\.wang$" 1;
default 0;
}
map $http_upgrade $connection_upgrade {
default upgrade;
@@ -29,6 +55,11 @@ server {
ssl_verify_depth 1;
ssl_crl /etc/relay/device-ca/crl.pem; # revocation from day one (V2)
# --- A3 cert→Host binding gate (runs AFTER ssl_verify_client) ---
# The one `if` guarding the single `return`; the actual rule is in the $cert_host_ok map above.
# Denies before ANY location is reached (protects /, /hook*, /live-sessions/:id/preview, …).
if ($cert_host_ok = 0) { return 403; }
location / {
proxy_pass http://127.0.0.1:7080; # frps vhost — routes by Host → subdomain
proxy_http_version 1.1;

22
deploy/nginx/njs/getCertSub.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
/**
* Types for the njs SAN extractor so `tsc --noEmit` resolves `import certsub from './getCertSub.js'`
* without `allowJs`. The runtime module is plain njs-compatible ECMAScript (getCertSub.js).
*/
/** The nginx request object surface `getCertSub` touches (only `variables.ssl_client_cert`). */
export interface NjsRequestLike {
readonly variables: { readonly ssl_client_cert?: string }
}
export interface GetCertSubModule {
/**
* Leftmost label of the first dNSName SAN in a PEM client cert (e.g. 'alice.terminal…' → 'alice').
* Returns '' on ANY parse failure / missing SAN / missing dNSName (fail-closed → the nginx map denies).
*/
extractLeftmostDnsLabel(pemClientCert: string): string
/** njs `js_set` entrypoint: reads `r.variables.ssl_client_cert` and delegates to the extractor. */
getCertSub(r: NjsRequestLike): string
}
declare const mod: GetCertSubModule
export default mod

View File

@@ -0,0 +1,241 @@
/*
* A3 / FIX C-native-3 — the SINGLE load-bearing tenant-isolation control.
*
* Runs INSIDE nginx as njs (an ECMAScript subset). `js_set $cert_sub certsub.getCertSub` exposes the
* leftmost dNSName SAN label of the (already CA-verified) client certificate to an nginx `map`, which
* compares it against the requested `$ssl_server_name` and `return 403`s on mismatch. A cert stamped
* `dNSName alice.terminal.yaojia.wang` may ONLY reach the `alice.*` subdomain; a cert for tenant A
* presented to tenant B's host is denied. This binding runs AFTER `ssl_verify_client` — by the time
* this code sees the cert, nginx has already proven it was signed by the device-CA.
*
* DESIGN: `extractLeftmostDnsLabel` is a PURE function using ONLY APIs common to njs AND Node, so the
* SAME source is unit-tested under Node/vitest (control-plane/test/getcertsub.test.ts against real
* A1-minted P-256 leaves) and runs unchanged under njs. No `Buffer`, no `atob`, no typed-array-only
* APIs: base64 decode and X.509/ASN.1 (DER) walking are hand-rolled over plain arrays of byte numbers.
*
* FAIL-CLOSED: ANY malformed input, missing SAN, missing dNSName, non-hostname bytes, or truncated DER
* returns '' (empty). An empty `$cert_sub` can never satisfy the map regex `^(?<s>[^:]+):(?P=s)\.`
* (which requires a non-empty label), so an empty result always DENIES. Never throws; never allows on
* doubt. Conservative by construction — this is the one control between tenants.
*/
/* -------------------------------------------------------------------------- */
/* PEM → base64 body */
/* -------------------------------------------------------------------------- */
/**
* Extract the base64 body between the FIRST `-----BEGIN ... CERTIFICATE-----` header and the next
* `-----END`. We anchor on `CERTIFICATE-----` (the tail of the BEGIN line) so the header letters
* (B,E,G,I,N,C — all valid base64 chars) can never leak into the decoded body. '' on absence.
*/
function pemBody(pem) {
var marker = 'CERTIFICATE-----'
var b = pem.indexOf(marker)
if (b < 0) return ''
b += marker.length
var e = pem.indexOf('-----END', b)
if (e < 0) return ''
return pem.substring(b, e)
}
/** Map one base64 alphabet char code to its 6-bit value; -1 for anything else (whitespace, '=', …). */
function b64Val(c) {
if (c >= 65 && c <= 90) return c - 65 // A-Z → 0..25
if (c >= 97 && c <= 122) return c - 71 // a-z → 26..51
if (c >= 48 && c <= 57) return c + 4 // 0-9 → 52..61
if (c === 43) return 62 // '+'
if (c === 47) return 63 // '/'
return -1
}
/**
* Decode base64 text to a plain array of byte numbers. Non-alphabet characters (newlines, the TAB that
* nginx `$ssl_client_cert` prepends to continuation lines, spaces, '=' padding) are skipped. The bit
* accumulator is masked to 24 bits every step so it never grows past double precision.
*/
function base64ToBytes(s) {
var out = []
var buffer = 0
var bits = 0
for (var i = 0; i < s.length; i++) {
var v = b64Val(s.charCodeAt(i))
if (v < 0) continue
buffer = ((buffer << 6) | v) & 0xffffff
bits += 6
if (bits >= 8) {
bits -= 8
out.push((buffer >> bits) & 0xff)
}
}
return out
}
/* -------------------------------------------------------------------------- */
/* Minimal DER (definite-length) reader */
/* -------------------------------------------------------------------------- */
/** Read a DER length starting at `pos`. Returns { len, next } or null (indefinite / overlong / OOB). */
function readLen(bytes, pos) {
var first = bytes[pos]
if (first === undefined) return null
if (first < 0x80) return { len: first, next: pos + 1 }
var numBytes = first & 0x7f
if (numBytes === 0 || numBytes > 4) return null // 0 = indefinite (illegal in DER); >4 = absurd
var len = 0
for (var i = 0; i < numBytes; i++) {
var b = bytes[pos + 1 + i]
if (b === undefined) return null
len = len * 256 + b
}
return { len: len, next: pos + 1 + numBytes }
}
/**
* Read one TLV at `pos`, bounded by `parentEnd` (the contentEnd of the immediate container; callers
* pass it so a child can never claim to extend past its parent even while still fitting the overall
* buffer — a crafted nested-length-inconsistent cert must not let the walk read sibling/trailing bytes
* as a child's content). `parentEnd` defaults to the total buffer length for top-level reads. Returns
* { tag, contentStart, contentEnd, next } or null on any inconsistency (out of bounds, high-tag-number
* form, length overrun past the parent OR the buffer). Single-byte tags only — every tag this walk
* cares about (SEQUENCE 0x30, [3] 0xA3, OID 0x06, BOOLEAN 0x01, OCTET STRING 0x04, dNSName 0x82) is
* single-byte, so a high-tag-number tag is treated as malformed (fail-closed).
*/
function readTlv(bytes, pos, parentEnd) {
var limit = parentEnd === undefined ? bytes.length : parentEnd
if (limit > bytes.length) limit = bytes.length // never trust a parent bound wider than the buffer
if (pos < 0 || pos >= limit) return null
var tag = bytes[pos]
if ((tag & 0x1f) === 0x1f) return null // high-tag-number form — unexpected → fail-closed
var l = readLen(bytes, pos + 1)
if (l === null) return null
var contentStart = l.next
var contentEnd = contentStart + l.len
if (contentEnd > limit) return null // overruns the parent container (or buffer) → fail-closed
return { tag: tag, contentStart: contentStart, contentEnd: contentEnd, next: contentEnd }
}
/** True iff this OID TLV's content is exactly `55 1d 11` = OID 2.5.29.17 (subjectAltName). */
function isSanOid(der, oid) {
if (oid.contentEnd - oid.contentStart !== 3) return false
return (
der[oid.contentStart] === 0x55 && der[oid.contentStart + 1] === 0x1d && der[oid.contentStart + 2] === 0x11
)
}
/** Valid hostname byte? [0-9A-Za-z.-] only — anything else rejects the whole label (fail-closed). */
function isHostByte(c) {
return (
(c >= 0x30 && c <= 0x39) || // 0-9
(c >= 0x41 && c <= 0x5a) || // A-Z
(c >= 0x61 && c <= 0x7a) || // a-z
c === 0x2d || // '-'
c === 0x2e // '.'
)
}
/** Decode the dNSName value bytes to a hostname string, or '' if any byte is outside [0-9A-Za-z.-]. */
function hostString(der, start, end) {
var s = ''
for (var i = start; i < end; i++) {
var c = der[i]
if (!isHostByte(c)) return ''
s += String.fromCharCode(c)
}
return s
}
/**
* Walk the certificate DER structurally to the SubjectAlternativeName extension and return the FIRST
* dNSName value. Structure:
* Certificate ::= SEQUENCE { tbsCertificate SEQUENCE { ..., extensions [3] EXPLICIT }, ... }
* Extensions ::= SEQUENCE OF Extension { extnID OID, critical BOOLEAN OPTIONAL, extnValue OCTET STRING }
* extnValue (for SAN) = GeneralNames ::= SEQUENCE OF GeneralName; dNSName is [2] IA5String (tag 0x82)
* Returns '' on any structural deviation, missing SAN, or SAN-without-dNSName.
*/
function firstDnsSan(der) {
var cert = readTlv(der, 0)
if (cert === null || cert.tag !== 0x30) return ''
var tbs = readTlv(der, cert.contentStart, cert.contentEnd)
if (tbs === null || tbs.tag !== 0x30) return ''
// Walk the DIRECT children of TBSCertificate for the [3] (0xA3) explicit extensions container.
var pos = tbs.contentStart
var extsContainer = null
while (pos < tbs.contentEnd) {
var child = readTlv(der, pos, tbs.contentEnd)
if (child === null) return ''
if (child.tag === 0xa3) {
extsContainer = child
break
}
pos = child.next
}
if (extsContainer === null) return '' // no extensions → no SAN
var exts = readTlv(der, extsContainer.contentStart, extsContainer.contentEnd)
if (exts === null || exts.tag !== 0x30) return ''
// Iterate each Extension looking for the SAN OID.
var epos = exts.contentStart
while (epos < exts.contentEnd) {
var ext = readTlv(der, epos, exts.contentEnd)
if (ext === null || ext.tag !== 0x30) return ''
var oid = readTlv(der, ext.contentStart, ext.contentEnd)
if (oid === null || oid.tag !== 0x06) {
epos = ext.next
continue
}
if (isSanOid(der, oid)) {
// Skip an optional BOOLEAN `critical`, then require the OCTET STRING `extnValue` — both bounded
// to THIS Extension's content so a lying extnValue length can't reach into a sibling extension.
var vpos = oid.next
var val = readTlv(der, vpos, ext.contentEnd)
if (val !== null && val.tag === 0x01) {
vpos = val.next
val = readTlv(der, vpos, ext.contentEnd)
}
if (val === null || val.tag !== 0x04) return ''
// The GeneralNames SEQUENCE and every GeneralName are bounded to the OCTET STRING's content:
// a GeneralNames length that overruns extnValue (but still fits the buffer) must NOT be read.
var gnames = readTlv(der, val.contentStart, val.contentEnd)
if (gnames === null || gnames.tag !== 0x30) return ''
var gpos = gnames.contentStart
while (gpos < gnames.contentEnd) {
var gn = readTlv(der, gpos, gnames.contentEnd)
if (gn === null) return ''
if (gn.tag === 0x82) return hostString(der, gn.contentStart, gn.contentEnd) // dNSName [2]
gpos = gn.next
}
return '' // SAN present but carries no dNSName (e.g. URI-only)
}
epos = ext.next
}
return '' // no SAN extension
}
/* -------------------------------------------------------------------------- */
/* Public surface */
/* -------------------------------------------------------------------------- */
/**
* Decode a PEM client certificate and return the LEFTMOST label of its FIRST dNSName SAN
* (e.g. 'alice.terminal.yaojia.wang' → 'alice'). '' on ANY failure (fail-closed → the map denies).
*/
function extractLeftmostDnsLabel(pemClientCert) {
if (!pemClientCert || typeof pemClientCert !== 'string') return ''
var body = pemBody(pemClientCert)
if (body === '') return ''
var der = base64ToBytes(body)
if (der.length === 0) return ''
var dns = firstDnsSan(der)
if (dns === '') return ''
var dot = dns.indexOf('.')
return dot < 0 ? dns : dns.substring(0, dot)
}
/** njs entrypoint referenced by `js_set $cert_sub certsub.getCertSub`. */
function getCertSub(r) {
return extractLeftmostDnsLabel(r.variables.ssl_client_cert || '')
}
export default { getCertSub: getCertSub, extractLeftmostDnsLabel: extractLeftmostDnsLabel }

View File

@@ -0,0 +1,7 @@
# nginx + njs (ngx_http_js_module) for the A3 cert→Host binding integration test.
# The official nginx Debian image ships the nginx.org apt repo, so the njs dynamic module installs at
# the exact matching version. This mirrors the VPS prereq: `load_module modules/ngx_http_js_module.so`.
FROM nginx:1.27
RUN apt-get update \
&& apt-get install -y --no-install-recommends nginx-module-njs \
&& rm -rf /var/lib/apt/lists/*

View File

@@ -0,0 +1,15 @@
# A3 cert→Host binding tests.
# make unit → Node/vitest: getCertSub SAN extractor + map-logic (no docker)
# make integration → real nginx+njs docker: positive 200 + cross-tenant 403 (needs docker+openssl)
# make test → both
REPO_ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))/../../../..)
.PHONY: unit integration test
unit:
cd $(REPO_ROOT)/control-plane && npx vitest run test/getcertsub.test.ts
integration:
bash $(dir $(lastword $(MAKEFILE_LIST)))run.sh
test: unit integration

View File

@@ -0,0 +1,39 @@
# A3 integration nginx.conf — exercises the REAL binding stack end-to-end: getCertSub.js (njs),
# js_set $cert_sub, the $cert_host_ok map, and the single `if → return 403`.
#
# ONLY delta from the production deploy/nginx/frp-mtls.conf: the location returns a stub `200 ok`
# instead of proxying to the frps vhost (no frps needed to prove the AUTH gate), and the listener is
# a plain :8470 (not 127.0.0.1) so the published container port is reachable. The binding logic —
# ssl_verify_client + njs SAN parse + map + if→403 — is byte-identical to prod.
load_module modules/ngx_http_js_module.so; # VPS prereq (main context)
events {}
http {
js_import certsub from njs/getCertSub.js; # mounted at /etc/nginx/njs/getCertSub.js
js_set $cert_sub certsub.getCertSub;
map "$cert_sub:$ssl_server_name" $cert_host_ok {
"~^(?<s>[^:]+):(?P=s)\." 1;
default 0;
}
server {
listen 8470 ssl;
server_name ~^.+\.terminal\.yaojia\.wang$;
ssl_certificate /certs/server.crt;
ssl_certificate_key /certs/server.key;
# data-path gate: only device-CA-signed client certs get past the handshake
ssl_verify_client on;
ssl_client_certificate /certs/ca.crt;
ssl_verify_depth 1;
# A3 cert→Host binding — the one `if` guarding the single return; rule lives in the map.
if ($cert_host_ok = 0) { return 403; }
location / { return 200 "ok\n"; } # PROD: proxy_pass http://127.0.0.1:7080; (frps vhost)
}
}

View File

@@ -0,0 +1,139 @@
#!/usr/bin/env bash
#
# A3 real nginx+njs integration acceptance (FIX C-native-3 / PLAN §1.2 B1 step 4).
#
# Proves the SINGLE load-bearing tenant-isolation control on a REAL nginx with the REAL njs module
# and the REAL getCertSub.js, using device-CA-signed client certs whose dNSName SAN is stamped
# exactly as the B1/A4 issuers stamp it:
#
# POSITIVE : alice client cert → https://alice.terminal.yaojia.wang → 200 (own host)
# NEGATIVE : bob client cert → https://alice.terminal.yaojia.wang → 403 (cross-tenant)
# NEGATIVE : alice client cert → https://bob.terminal.yaojia.wang → 403 (cross-tenant)
# POSITIVE : bob client cert → https://bob.terminal.yaojia.wang → 200
# NEGATIVE : NO client cert → https://alice.terminal.yaojia.wang → 400 (ssl_verify_client on)
#
# Requires: docker + openssl. If docker is unavailable, this same script is the VPS/CI acceptance
# step (run it there). Exit 0 iff every assertion holds.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NJS_FILE="$(cd "$SCRIPT_DIR/../../njs" && pwd)/getCertSub.js"
CONF="$SCRIPT_DIR/nginx.test.conf"
IMAGE="wt-nginx-njs:a3-test"
CONTAINER="wt-a3-nginx-$$"
HOST_PORT="${HOST_PORT:-18470}"
WORK="$(mktemp -d)"
cleanup() {
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
rm -rf "$WORK"
}
trap cleanup EXIT
echo "==> workdir: $WORK"
echo "==> njs source under test: $NJS_FILE"
# ---------------------------------------------------------------------------
# 1. Mint the device CA, a server cert, and two client leaves with dNSName SANs
# ---------------------------------------------------------------------------
gen_ca() {
openssl ecparam -name prime256v1 -genkey -noout -out "$WORK/ca.key"
openssl req -x509 -new -key "$WORK/ca.key" -sha256 -days 1 -subj "/CN=device-CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-out "$WORK/ca.crt"
}
gen_server() {
openssl ecparam -name prime256v1 -genkey -noout -out "$WORK/server.key"
openssl req -x509 -new -key "$WORK/server.key" -sha256 -days 1 \
-subj "/CN=*.terminal.yaojia.wang" \
-addext "subjectAltName=DNS:*.terminal.yaojia.wang" \
-out "$WORK/server.crt"
}
# gen_client <name> <subdomain> → mints <name>.key/.crt with SAN dNSName <subdomain>.terminal...
gen_client() {
local name="$1" sub="$2"
openssl ecparam -name prime256v1 -genkey -noout -out "$WORK/$name.key"
openssl req -new -key "$WORK/$name.key" -subj "/CN=$sub" -out "$WORK/$name.csr"
openssl x509 -req -in "$WORK/$name.csr" -CA "$WORK/ca.crt" -CAkey "$WORK/ca.key" \
-CAcreateserial -days 1 -sha256 \
-extfile <(printf 'subjectAltName=DNS:%s.terminal.yaojia.wang,URI:spiffe://terminal.yaojia.wang/account/a1/host/%s\nextendedKeyUsage=clientAuth\nbasicConstraints=critical,CA:FALSE\n' "$sub" "$sub") \
-out "$WORK/$name.crt"
}
echo "==> minting CA + server + client (alice, bob) certs"
gen_ca
gen_server
gen_client alice alice
gen_client bob bob
# ---------------------------------------------------------------------------
# 2. Build the nginx+njs image and start the container
# ---------------------------------------------------------------------------
echo "==> docker build ($IMAGE)"
docker build -q -t "$IMAGE" "$SCRIPT_DIR" >/dev/null
echo "==> docker run ($CONTAINER) on :$HOST_PORT"
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
docker run -d --name "$CONTAINER" \
-p "$HOST_PORT:8470" \
-v "$NJS_FILE:/etc/nginx/njs/getCertSub.js:ro" \
-v "$CONF:/etc/nginx/nginx.conf:ro" \
-v "$WORK:/certs:ro" \
"$IMAGE" >/dev/null
# nginx -t inside the running image (config sanity on the real binary)
echo "==> nginx -t (real binary)"
docker exec "$CONTAINER" nginx -t
# wait for readiness
for i in $(seq 1 30); do
if curl -sk -o /dev/null "https://alice.terminal.yaojia.wang:$HOST_PORT/" \
--resolve "alice.terminal.yaojia.wang:$HOST_PORT:127.0.0.1" \
--cert "$WORK/alice.crt" --key "$WORK/alice.key" 2>/dev/null; then
break
fi
sleep 0.3
done
# ---------------------------------------------------------------------------
# 3. Assert positive (200) and negative (403 / 400) cases
# ---------------------------------------------------------------------------
# hit <host> <clientname|-> → prints HTTP status code
hit() {
local host="$1" client="$2"
local args=(-sk -o /dev/null -w '%{http_code}'
--resolve "$host:$HOST_PORT:127.0.0.1"
"https://$host:$HOST_PORT/")
if [ "$client" != "-" ]; then
args+=(--cert "$WORK/$client.crt" --key "$WORK/$client.key")
fi
curl "${args[@]}" || echo "000"
}
FAILED=0
assert() {
local label="$1" expected="$2" actual="$3"
if [ "$actual" = "$expected" ]; then
echo " PASS $label$actual"
else
echo " FAIL $label → expected $expected, got $actual"
FAILED=1
fi
}
echo "==> assertions"
assert "POSITIVE alice-cert @ alice host" 200 "$(hit alice.terminal.yaojia.wang alice)"
assert "NEGATIVE bob-cert @ alice host" 403 "$(hit alice.terminal.yaojia.wang bob)"
assert "NEGATIVE alice-cert @ bob host" 403 "$(hit bob.terminal.yaojia.wang alice)"
assert "POSITIVE bob-cert @ bob host" 200 "$(hit bob.terminal.yaojia.wang bob)"
assert "NEGATIVE no-cert @ alice host" 400 "$(hit alice.terminal.yaojia.wang -)"
if [ "$FAILED" -ne 0 ]; then
echo "==> A3 INTEGRATION FAILED"
docker logs "$CONTAINER" 2>&1 | tail -20 || true
exit 1
fi
echo "==> A3 INTEGRATION PASSED (positive 200 + cross-tenant 403 on real nginx+njs)"

View File

@@ -24,6 +24,85 @@
> 新会话读到的第一块。保持准确,只描述"此刻"。
### 🔐 TUNNEL AUTOMATION — 零接触隧道注入(客户永不碰证书/密钥;2026-07-08)
- **计划**: [PLAN_TUNNEL_AUTOMATION.md](./PLAN_TUNNEL_AUTOMATION.md)(design-locked)。目标:host 一条命令 onboard、device 登录一次即在硬件里生成不可导出密钥→CSR→拿证书,**无 .p12/AirDrop,私钥永不离设备**。三轨:A 控制面/PKI、B host agent、C 原生客户端(iOS/Android/desktop)。基础设施(frps/device-CA/frp-client-CA/nginx:8470 mTLS)已在 VPS M1 上线;本工作流建的是**自动化**(签发端点、njs cert→Host 绑定、硬件 keygen、host onboard)。
- **分支**: `feat/tunnel-automation`(自 develop)。走 **MVP fast-path §7**(10 个 tracked task,见任务表 #1#10),依赖图强制:A1 crypto → A2 契约 → A3 nginx 绑定为顺序地基,之后按职责(backend/host/iOS)扇出。
- **编排(loop 模式)**: 每个 task = 一个 Workflow(TDD builder → `typescript-reviewer`+`security-reviewer` 并行交叉验证 → 自动修 confirmed critical/high → orchestrator 独立复验 tests 绿 → 记本日志 → 解锁下游)。三个 workflow 并行:A1(backend/crypto)、A2a(backend/契约)、B2h+B3(host/agent),文件互斥不冲突。
- **地基三块全绿(均 orchestrator 独立复验,非仅采信 agent)**:**[x] A1**(x509-assembler+csr-ec;40 测试+openssl verify+tsc 干净)、**[x] A2a**(契约编辑;relay-auth 133 + relay-contracts 85)、**[x] B2h+B3**(frpcToml+frpcBinary;agent 全套 184)。详见下方条目。**[x] A3**(nginx cert→Host 绑定)DONE — orchestrator 已亲自审计 njs 解析器 + zone-anchor 加固(见下方条目)。
- **Wave 2 全绿**: **[x] B1**(frp-client P-256 签发 + host keygen)、**[x] A4**(device-enroll + 会话令牌)。**统一复验**:control-plane 全套 **206/206**`tsc --noEmit` exit 0(B1+A4 合并态干净,交叉噪声已消)。**A4 交叉验证抓到并修复 1 个 CRITICAL**:enroll 路由曾把客户端传的 `subdomain` 直接烧进 device 叶子 dNSName SAN 且无归属校验→account A 的 bearer 可铸 `victim.*` 证书绕过隔离;已修(归属校验 `ownerOfSubdomain==accountId` + 复用 host 的 charset/reserved 规则 + 用规范化后的标签),负例测试覆盖(B 请求 A 的 subdomain→403、未认领→403、非法→400、自有→201),orchestrator 已亲读 device-enroll.ts 审计确认。
- **Wave 3 三路扇出**(不同职责/包,文件全互斥):**[x] C-iOS**(iOS:SecureEnclave keygen + CSR + Keychain 重构)DONE — orchestrator 独立复验 `swift build` 干净 + `swift test` **27/27** + **跨语言:真 control-plane verifyCsrPoPEc 接受 Swift 生成的 CSR(ok=true)**;**[x] B5**(host:两服务安装 + BIND_HOST S-GATE + `pair --install`)DONE — 独立复验 agent **214/214** + tsc 干净;**[x] A6**(backend:/renew + rotate.ts 真 X.509)DONE — 独立复验 control-plane **224/224** + tsc 干净;**已复用 A4 教训**(/renew 身份取自当前 mTLS 证书 + 注册表记录、绝不信 body,anti-smuggling 测试覆盖)。**[x] B4**(host 原生 run-loop + frpc 监督 + health probe + keystore P-256)DONE — 独立复验 agent **247/247**
- **✅ MVP 全 10 任务代码完成 + 集成复验全绿(2026-07-08)**:**716 测试跨全部包**(relay-contracts 85 / relay-auth 133 / control-plane 224 / agent 247 / iOS ClientTLS 27),4 个 TS 包 tsc 全 exit 0,swift 27。MVP 分层保证证实(契约→auth→控制面 签发/注入/续期→host agent 安装/运行/keystore→iOS 硬件注入 全共存绿)。
- **交叉验证共抓修 5 个真缺陷(否则静默上线)**:A4 客户端 subdomain 跨租户绕过(CRITICAL)、B5 loopback 守卫绕过 ×2(HIGH)、A6 device 续期不延期(HIGH)、B4 health monitor 永不 healthy + frpc 管道不排空致挂起(HIGH)——每个安全关键修复 orchestrator 均亲自审计。
- **[x] 加固 pass DONE**:9 项 confirmed-review 修复(Ed25519 长度守卫、csr-ec 不可变 + timingSafeEqualBytes、getCertSub readTlv 父容器边界[附 crafted-DER 回归:修前泄 'evil' 修后 '']、renew 有界限流 + csr max + 当前证书链/过期校验、systemd 全路径控制符守卫、keystore prime256v1 校验、@peculiar/asn1-* 声明为直接依赖)。**最终全绿 734 测试**(relay-contracts 85 / relay-auth 133 / control-plane 235 / agent 254 / iOS ClientTLS 27),4 个 TS 包 tsc 全干净。
- **✅ MVP 代码全部完成(10/10 任务)+ 5 个真缺陷经交叉验证抓修。剩余纯属"需真基础设施"**(见下 ACC RUNBOOK):不能在本环境伪造。
- **「继续」round — 集成推进(2026-07-09)**:**[x] #11 frpc tar.gz 解压**(traversal-safe USTAR + 真 frp v0.61.1 sha256 pins;**对真 frp 归档解出的 frpc 与 `tar -xO` 逐字节一致** 14,259,442 字节;agent 254→267)。**[x] #14 原生 boot 接线**(boot/native-ca.ts 建 frp-client-CA+device-CA 两个 P-256 CA[DEV 自签/PROD KMS fail-fast];main.ts 组合 issuers 共享一个 DeviceStore + 注册 device-enroll + renew 路由 + 真 anchors + 生产 fail-closed 断言;Ed25519 relay /enroll 原样不动)。**wired app e2e 证实**(integration-native.test.ts 6 测试,fastify inject):device mint→/device/enroll(自有 alice)→201 真叶子链到 device-CA + dNSName → /device/:id/renew→201 同 subdomain;归属门 e2e:未认领→403、跨租户 B-on-A→403、缺 token→401;host 叶子→/renew→201 链到 frp-client-CA、不信任 CA→401。**[x] #15 原生 host /enroll P-256 arm DONE**:/enroll 按 CSR 密钥类型分叉(EC-P256→原生 frp-client 叶子 via 已接线 hostSigner;Ed25519→relay 原样);**把安全关键的配对门 `gateAndConsumePairingCode` 从 redeem.ts 抽出供两臂共用**(单次 CAS+锁定+过期+PoP/防替换 顺序不变,relay 逐字节证实不变);**anti-smuggling 再守**(dNSName SAN 只来自服务端分配的 subdomain,body 的 subdomain 惰性——负例证实);hostContentSecret:null。host onboard e2e 证实:配对码→/enroll(EC CSR)→201 叶子链到 frp-client-CA + dNSName → /renew→201 同 subdomain;单次重放/防替换负例绿。**当前全绿 758 测试**(cp 246 / agent 267 / relay-auth 133 / relay-contracts 85 / iOS 27),4 个 TS 包 tsc 全干净。
- **🎯 后端 onboard 全链路已接线 + wired-app e2e 证实**:host enroll + device enroll + host/device renew + 归属隔离门 + 链校验 全部跑通(fastify inject)。**代码侧至此完成**;剩余小对齐(#16:agent pair.ts 解析原生响应形状、生产 NODE_ENV 守卫、machineId 去重需 relay-contracts 加列)+ 真基础设施(KMS/nginx 部署/真机 iOS/真 VPS frps)。
- **ACC RUNBOOK(#10;需 VPS `8.138.1.192` + 真 iPhone + KMS,不可本地伪造)**:
1. **KMS/CA 物料**:为 frp-client-CA(P-256)+ device-CA(P-256)provision KMS 密钥,`buildCaSigner` fail-fast 需真 KMS ref;boot 装配 frpclient-issue/device-issue/rotate 用真签发器。
2. **接线(#14)**:control-plane main.ts 注册 device-enroll + renew(**并传 hostCaAnchorsDer/deviceCaAnchorsDer**,否则链/过期校验静默 no-op)+ device-grant/session。
3. **nginx(部署,安全关键)**:装 getCertSub.js 到 /etc/nginx/njs/ + main-context `load_module ...js_module.so`;frp-mtls.conf 已有 cert→Host map;renew 若走 header 认证,**nginx 必须由 $ssl_client_cert 设 x-client-cert 并 strip 客户端自带**。
4. **frpc 真二进制(#11.9)**:B3 tar.gz 解压 + 换真 sha256 校验和(frp 发布 checksums),host `run` 才能真监督 frpc。
5. **host onboard**:真 Mac/Linux 跑 `pair <code> --install`(BIND_HOST loopback S-GATE 已守)→ 两 systemd/launchd unit → `https://<sub>.terminal.yaojia.wang` 可达。
6. **iOS 真机**:Apple entitlement + 物理设备构建 App(接 ClientTLS enroll seam,#12)→ 登录 → SE keygen → enroll → mTLS 连。
- **已在本环境证实(无需 VPS)**:真 nginx+njs docker 跨租户 **403 / 自有 200 / no-cert 400**;Swift CSR 被真 control-plane verifyCsrPoPEc 接受;BIND_HOST S-GATE fail-closed;734 单元/集成测试。**剩余为真机/真网/真 KMS 验收。**
- **⚠️ B5 评审揭出两个真集成缺口(记 task #13,阻塞完整 host-side ACC)**:① keystore.loadIdentity 仍写死 Ed25519、不认 B1 的 `alg` 判别式 → 存的 P-256 身份加载错;② cli.ts `run` 对所有 host 无条件走 legacy runTunnel(relay rendezvous)→ 原生 host 未监督 frpc(即 B4/H4 原生 run-loop,我 10-task 分解从 B4 跳到了 B5)。这两块 + frpc tar.gz 解压(#11.9)是 host 真正打通隧道所需。**A2b 会话令牌子系统(login→device:enroll token)已并入 A4**——它是门控所有 device 注入的唯一 bootstrap 凭据,过于安全敏感,orchestrator 在建 A4(消费该 token 的端点)时亲自设计(计划允许 MVP 用 stub OAuth / 手贴 pairing code)。
- **HARDENING backlog(task #11,非阻塞,confirmed review findings 延后)**: A1 —Ed25519 签名长度守卫、`@peculiar/asn1-*` 声明为直接依赖、csr-ec `UNIFORM_FAILURE` 改每次新字面量、复用 `timingSafeEqualBytes`、signer/sigAlg 运行时交叉校验;B3 —**tar.gz 解压(frp 发 .tar.gz;当前校验+放置的是压缩包非内层 frpc 二进制,B5 pair --install 端到端前必须补)**、把占位 sha256(64 个 0)换成 frp 发布校验和。
#### [x] A2a — 契约编辑: `enroll` right + SPIFFE `/device/` arm + `spiffeIdFor(kind)`(FIX C-native-1, H-cp-2, H-native-6;2026-07-08)
- **交付**: 向后兼容的冻结契约编辑(**会话令牌子系统刻意不在本轮**)。① `relay-contracts/src/capability/token.ts`:`CapabilityRight` 联合类型 + `CapabilityRightSchema` z.enum 各加 `'enroll'`(`relay-auth/capability/issue.ts` 自动接受,已由 issue→verify 测试证实)。② `relay-auth/src/types.ts`:`SPIFFE_ID_RE`+`SpiffeIdSchema``(?:host|device)` 同时接受两 arm,保留 `..`/`*` 拒绝;host 形式仍原样通过。③ `relay-auth/src/agent/spiffe.ts`:`spiffeIdFor(accountId, id, domain?, kind:'host'|'device'='host')`——现有 3-arg 调用者(`control-plane/ca/issue.ts`)不变;`PARSE_RE` 捕获 kind;`parseSpiffeId → {accountId,id,kind}`;`index.ts` 导出 `SpiffeKind`。④ `relay-auth/src/agent/verify-mtls.ts`:新 parse 形状 + **host-only kind 守卫**(`not_host_cert`)——`/device/` 证书永不能在 host 控制信道被接受(防 device-id 与 hostId 碰撞跨租户),**收紧**信任而非放宽。`relay.` trust-domain 前缀刻意保留(按 FIX H-2 延后;强制点是 dNSName SAN 非 SPIFFE URI)。
- **测试(新)**: `relay-auth/test/spiffe.test.ts`(host 向后兼容;device arm;**cross-track**:CP 签发端将来盖的原始 device SAN 能被 relay-auth schema+parser 接受且等于共享 builder 输出→签发方/验证方不漂移;malformed/traversal/wildcard/unknown-kind 拒绝)、`relay-auth/test/mtls.test.ts`(第 29 行 round-trip 断言改 `{accountId,id,kind}` 锁步 + device 证书在 host 信道被拒)、`relay-auth/test/capability.test.ts`(issue→verify `rights:['enroll']`)、`relay-contracts/test/capability-pairing.test.ts`(`parse('enroll')` ok / 未知仍抛)。
- **验证(orchestrator 独立实测,非仅采信 agent)**: TDD RED-first(6 relay-auth + 2 relay-contracts 先红)。`cd relay-auth && npx vitest run`**133 passed**;`cd relay-contracts && npx vitest run`**85 passed**;`git diff` 复核 3 处信任关键 hunk(regex 非捕获组无放宽 / verify-mtls 守卫为收紧 / token 加法式)。交叉验证:两评审均 **approve**,仅 2 个 LOW(`m[2]! as SpiffeKind` 断言可换运行时守卫;trust-domain 清理延后)——不阻塞。
- **偏差/决策**: `parseSpiffeId` 返回 `{accountId,id,kind}` 无 hostId 别名(锁步更新唯一调用者 verify-mtls);新增 `not_host_cert` 防御分支。**阻塞**: 无。**下一步**: A4 device-issue 用 `spiffeIdFor(accountId, deviceId, trustDomain, 'device')` 盖 device SAN。
#### [x] A1 — x509-assembler + csr-ec(KMS-signed 真 X.509 + P-256 PoP;FIX C-1;2026-07-08)
- **交付**: 单一签发原语,附加式(不改 issue.ts/rotate.ts,那是 A6)。① `control-plane/src/ca/x509-assembler.ts`(NEW):`assembleCertificate(input)``@peculiar/asn1-x509` schema 建 v3 TBSCertificate + `AsnConvert.serialize`(不手写整证 DER),把**序列化后的 TBS** 交 `CaSigner.sign(tbsDer)` 签(KMS 边界——原始 CA 私钥永不载入,INV9),再包成 Certificate(嵌入 exact `tbsCertificateRaw`)。两签名族:`ed25519`(OID 1.3.101.112,raw 64B 入 BIT STRING)与 `ecdsa-p256`(OID 1.2.840.10045.4.3.2,导出 `normalizeEcdsaSignatureToDer` 把 raw P1363 r‖s 转 DER ECDSA-Sig-Value,正整数前导零处理;已 DER 则透传);tbs.signature 与外层 signatureAlgorithm 同一 OID。subject pubkey 收 CryptoKey 或 SPKI DER。② `control-plane/src/ca/csr-ec.ts`(NEW):`verifyCsrPoPEc(csr)→{ok,embeddedPubSpki}` 解析 P-256 PKCS#10、强制 EC P-256、验自签 PoP,**fail-closed + uniform**(malformed/非 P256/坏签统一 `{ok:false, []}`);`buildCsrEc()` 测试助手。③ `boot/ca-wiring.ts`(EDIT):加 `inProcessP256CaSigner`(TEST/DEV P-256 CA,返回 raw P1363)。
- **验证(orchestrator 实测)**: `npx vitest run test/x509-assembler.test.ts test/csr-ec.test.ts test/ca.test.ts test/interop.test.ts test/rotate.test.ts`**5 files / 40 passed**;`npx tsc --noEmit` exit 0;全套 132 passed 无回归。5 个验收门全过:(a) Ed25519 与 P-256 叶子各 `new x509.X509Certificate(der)` 重解析;(b) 各自对 CA 公钥验签 + 错 CA 负例;(c) **SAN 含 dNSName `alice.terminal.yaojia.wang` + URI `spiffe://…/host/alice` 双值 round-trip**(A3 njs 要解析的正是这些字节);(d) 合法 CSR ok:true、篡改/垃圾/Ed25519/P-384 统一 ok:false;(e) **真 openssl 3.0.18 `x509 -text` + `verify -CAfile ca.pem leaf.pem` → OK**
- **交叉验证**: ts + security 两评审,verdict warn/approve,**0 confirmed critical/high**(未触发自动修);9 个 medium/low 记入 **task #11 HARDENING**(依赖声明、Ed25519 长度守卫、UNIFORM_FAILURE 不可变、signer/sigAlg 交叉校验等,均非阻塞)。**阻塞**: 无。**下一步**: B1 `frpclient-issue.ts` / A4 `device-issue.ts``assembleCertificate` 出叶子;设备/host P-256 CSR 经 `verifyCsrPoPEc` 验。
#### [x] B2h+B3 — 原生 frpc.toml 写入器 + 固定版 frpc 校验下载(host-agent;2026-07-08)
- **交付**(严守 lane,未碰 service/install、cli、keys、CA、control-plane、relay-*)。① `agent/src/transport/frpcToml.ts`(NEW):`buildNativeFrpcToml(opts)` 出 frp v0.61 TOML(PLAN_NATIVE_TUNNEL §4:serverAddr 默认 8.138.1.192/可配、serverPort 443、transport.tls.enable/serverName=frp.terminal.yaojia.wang/disableCustomTLSFirstByte/certFile/keyFile/trustedCaFile、auth.token、`[[proxies]]` type=http subdomain/localIP=127.0.0.1/localPort);**反 SSRF 硬不变量**:localIP 非 loopback → `FrpcTomlError`(负例测试);边界校验 DNS-label subdomain、port 1-65535、非空路径/token、控制符拒绝、Windows 路径转义;弃用 v0.8 `[common]/tls_enable`(断言不存在)。② `agent/src/provision/frpcBinary.ts`(NEW):`detectFrpcPlatform`(darwin/linux×arm64/amd64)、`FRPC_RELEASES` 固定 {version,url,sha256}、`provisionFrpc` 走 buildBinary 纪律:fetch→写临时到盘→**SHA-256 校验先于 place/exec**→符合 chmod 0755+原子 rename、不符 rm 临时+throw(什么都不放);fetch/fs/sha256 皆可注入,默认 fetch 强制 https(无 -k)。
- **验证(orchestrator 实测)**: `cd agent && npx vitest run test/frpcToml.test.ts test/frpcBinary.test.ts`**18 passed**;全套 184 passed;tsc exit 0。TDD RED-first 确认。
- **交叉验证 + orchestrator 已修**: 评审 verdict warn,2 个 confirmed(未达自动修的 critical/high 门,orchestrator 亲手补):**MEDIUM** `defaultFetch``fetch()` 未包 try/catch→网络失败抛未类型化错(已包成 `FrpcProvisionError`);**LOW** 固定临时名 TOCTOU(已改 `${TMP_NAME}.${pid}.${randomBytes}` 每次唯一)。修后 frpcBinary 7/7 仍绿。**延后(task #11)**: **tar.gz 解压(当前放置的是压缩包,B5 端到端前必补)**、占位 sha256 换真校验和、可选签名校验。**阻塞**: 无。
#### [x] A3 — nginx cert→Host 绑定(FIX C-native-3;单一承重租户隔离控制;2026-07-08)
- **交付**: 在 `ssl_verify_client on`(device-CA)之上,njs 解析叶子 dNSName SAN,`map` 要求其最左标签 == 请求 SNI 最左标签、否则 `return 403`,关闭 Model A skeleton-key。① `deploy/nginx/njs/getCertSub.js`(NEW):纯函数 `extractLeftmostDnsLabel(pem)`——手写 base64→**定长 DER 遍历**到 SAN(OID 55 1D 11)→取第一个 dNSName(0x82,而非 spiffe URI)→最左标签;主机名字节限 `[0-9A-Za-z.-]`(挡 `:` map-key 走私);**任何** 畸形/缺 SAN/截断 DER → `''`(fail-closed,空值永不满足 map)。只用 njs∩Node API,故同一源码 Node 单测 + njs 生产同跑。② `deploy/nginx/frp-mtls.conf`(EXTEND):`js_import`+`js_set $cert_sub`+**zone-anchored map** `"~^(?<s>[^:]+):(?P=s)\.terminal\.yaojia\.wang$"`+单个 `if ($cert_host_ok = 0) { return 403; }`(规则在 map,不散 if)。③ docker 集成测试 `deploy/nginx/test/integration/{Dockerfile,run.sh,…}`
- **验证(orchestrator 亲自审计 + 独立实测)**: 亲读 getCertSub.js 全文确认解析器结构正确且真 fail-closed(PEM body 锚 `CERTIFICATE-----` 尾防头字母泄入 base64、拒不定长/超长长度、结构化走 TBS→[3]→Extensions→SAN)。`npx vitest run test/getcertsub.test.ts`**22 passed**(含真 A1-assembler 铸的 P-256 叶子);全套 control-plane 153 passed;tsc exit 0。**真 nginx+njs docker 集成 5/5**:alice-cert@alice=200、**bob-cert@alice=403(跨租户负例)**、alice-cert@bob=403、bob-cert@bob=200、no-cert=400。
- **orchestrator 加固(评审 confirmed MEDIUM,亲手补)**: map 原为 label-only `(?P=s)\.`,不锚全 zone——虽被 stream SNI 路由 + `server_name` 正则**双重**约束(实际不可利用),仍改为**锚定全 zone `.terminal.yaojia.wang$`** 使 map **自足**(不依赖 server_name 正确性),加 `alice.evil.com→deny` 断言(label-only 会误放)。评审 verdict approve/warn,自动修移除了 security 评审遗留的 scratch 测试文件(曾破 tsc);2 个 LOW(readTlv 父容器边界、critical=true 覆盖)记 task #11(均无利用路径,cert 已过 ssl_verify_client)。**阻塞**: 无。
#### [x] B1 — frp-client P-256 host 叶子签发 + host P-256 keygen/CSR(FIX H-host-2/H-host-4;2026-07-08)
- **交付**: ① `control-plane/src/ca/frpclient-issue.ts`(NEW)`createFrpClientLeafSigner` — 仿 issue.ts 形状但**P-256**:CSR 经 `verifyCsrPoPEc`(A1)验、host 注册表门控(bound/active/non-revoked + 嵌入 EC-SPKI==注册 pubkey,uniform `LeafSignError`,拒绝时 CA `sign()` 不可达)、经 `assembleCertificate`(A1)`sigAlg:'ecdsa-p256'` + frp-client-CA 签;SAN = dNSName `<sub>.terminal.yaojia.wang`(A3 强制点)+ URI `spiffeIdFor(accountId,sub,trustDomain,'host')`;CA:false、EKU clientAuth、KU digitalSignature、[now-60s,now+24h]。② `agent/src/keys/identity.ts`(EXTEND)加 `alg:'ed25519'|'p256'` 判别式 + `generateP256Identity`/`p256IdentityFromPrivatePem`(私钥永不出机);③ `agent/src/enroll/csr.ts`(EXTEND)P-256 ecdsa-with-SHA256 PKCS#10,Ed25519 路径逐字节不变。
- **验证(orchestrator 独立复验,A4 并发期隔离跑)**: control-plane `frpclient-issue.test.ts` **10/10**(叶子 re-parse + 对 frp-client-CA 验签、SAN 双值 + relay-auth parseSpiffeId 认 kind 'host'、EKU/CA:false、gate uniform 拒绝、**真 agent enroll/csr 铸的 P-256 CSR 过 verifyCsrPoPEc 且 pubkey 逐字节流入叶子**);agent `identity/csr/cli` **27/27**(全套 194/194)。my-files tsc 干净。
- **交叉验证**: 评审 approve/warn;自动修 no-op(唯一 "high" 是**误判**——实为并发 A4 的 `DeviceLeafSignerDeps.devices` tsc 缺失,fix agent 正确拒碰 A4 文件)。B1 自身 findings 全 LOW(记 #11):**frp-client 签发器尚未接入 enroll 路径(redeem.ts:110 仍用通用签发器)**、gate step-2 单测缺、cli.test `as never`。**偏差**:AgentIdentity 加必填 `alg` 判别式(改了一处 mock);P-256 pubkey 存 EC SPKI DER(匹配注册表比对)。**阻塞**: 无。frps refused-without-cert bring-up 未本地跑(无 frps 二进制;已在 VPS M1 证实)→ VPS 验收。
#### [x] A4 — device enrollment + 最小会话令牌子系统(FIX C-2, C-native-1;2026-07-08)
- **交付**(control-plane,全 deny-by-default / uniform-reject / immutable / Zod-boundary)。① `auth/session.ts`:`mintDeviceEnrollToken`/`verifyDeviceEnrollToken`/`loginToAccountId`(单租户 STUB seam)/`requireEnrollRight`;device:enroll = §4.3 capability token(`rights:['enroll']`,sub=accountId,aud='device-enroll',10m TTL 夹 [60s,60m]),用 relay-auth `signPaseto` 铸(**非** `issueCapabilityToken`——后者硬夹 ≤60s connect clamp + 要求 host/DPoP,与"独立于 connect clamp 的无 host 分钟级 enroll token"[FIX C-native-1] 结构冲突;同一 Ed25519 v4.public 原语 + 同 §4.3 body,冻结验证器原样接受)。② `registry/devices.ts`:`DeviceRecord`+`DeviceRegistry`+`createMemoryDeviceStore`(镜像 hosts.ts,INV8 版本化状态原子交换)+ 每账户 CAP + 滑窗 rate-limit。③ `ca/device-issue.ts`:P-256 device 叶子经 assembleCertificate,注册表门控(verifyCsrPoPEc + bound/active/non-revoked + pubkey-match,uniform),SAN dNSName + `spiffeIdFor(...,'device')`,EKU clientAuth/CA:false。④ `api/device-enroll.ts`:`buildDeviceEnrollRouter`(可注册 fastify 插件),POST /device/enroll + /device/attest/challenge(stub);401/403/429/400 uniform。⑤ `store/ports.ts` ADD-ONLY DeviceStore 端口。
- **交叉验证抓到并修复 CRITICAL**(两评审均 block): enroll 路由曾把客户端 `subdomain` 直烧进叶子 dNSName SAN 且**无归属校验 + 无 charset/reserved 限制** → account A bearer 可铸 account B 的 `victim.terminal.yaojia.wang` 证书,A3 nginx 会放行 → **击穿整个隔离模型**。自动修已闭合:`accountId` 只来自已验 token(`token.sub`);`normalizeSubdomain`+`isValidSubdomain`(复用 host onboarding 规则)+ 归属门控 `ownerOfSubdomain(sub)===accountId`(否则 403;未认领与外账户均 403,无存在性 oracle);SAN 用规范化后标签(非原始客户端字段)。
- **验证(orchestrator 独立 + 亲审)**: 亲读 `device-enroll.ts` 确认修复正确;`grep` 确认负例测试覆盖(B 请求 A 的 subdomain→403 line190、未认领→403、非法→400、wrong-right bearer→403、自有→201)。**统一 `npx vitest run` → 25 files / 206 passed**;`npx tsc --noEmit` exit 0(含并发 B1 的 frpclient-issue.ts,无交叉噪声)。
- **偏差/延后(记 #11)**: enroll token 的 `cnf.jkt` 是随机占位符 → **DPoP 绑定推迟,当前是 bearer token**(§5 bootstrap 滥用仅靠 短 TTL+cap+rate 缓解,符合 MVP 计划);cap/rate 与 registerDevice 非原子(TOCTOU MEDIUM);rate-limit 在 CSR PoP 之后(LOW);device.* 审计未加(共享 enum,推迟);DeviceRecord 类型 + 内存 store 暂放 registry/devices.ts(model/records.ts + store/memory.ts 本任务冻结)。插件未接入 main.ts(集成步骤)。**阻塞**: 无。
#### [x] C-iOS — SecureEnclave keygen + 手写 CSR + DeviceEnrollmentClient + Keychain enroll 重构(FIX C-native ClientTLS trap;2026-07-08)
- **交付**(全在 `ios/Packages/ClientTLS` leaf 包——身份/mTLS 类型所在处,仅依赖 Security/Foundation;**全程 SecKey/Security.framework,避开 ClientTLS 陷阱**:不用 CryptoKit SecureEnclave.P256,不用 SecKeyCreateWithData-over-SE(-25300))。① `SecureEnclaveKey.swift`(NEW):`SecKeyCreateRandomKey` + `kSecAttrTokenIDSecureEnclave` + ECSECPrimeRandom(256) + `kSecAttrIsPermanent` + `SecAccessControl(.privateKeyUsage)`;私钥不可导出/永不出机;Simulator 有软件 fallback + SE-unavailable 错误。② `CertificateSigningRequest.swift`(NEW):手写 canonical DER PKCS#10(id-ecPublicKey+prime256v1 SPKI),`SecKeyCreateSignature(.ecdsaSignatureMessageX962SHA256)` 自签,sig OID ecdsa-with-SHA256。③ `DeviceEnrollmentClient.swift`(NEW):POST /device/enroll(bearer + A4 body + base64 CSR)+ attest/challenge + /device/:id/renew seam + `EnrollmentResult.isRenewalDue` 轮换 seam。④ `KeychainClientIdentityStore.swift`(MOD):加 `enroll()/renew()/remove()` —— 留 SE key、`SecItemAdd(kSecClassCertificate)` 存回叶子、`loadIdentity``SecItemCopyMatching(kSecClassIdentity)`,使 **MutualTLSChallengeResponder/ClientIdentity/urlCredential 原样复用**;`loadIdentity` 优先 SE 身份、回退 legacy .p12(双信任迁移窗);保留 PKCS12Importer。
- **验证(orchestrator 独立实测)**: `swift build` 干净;`swift test` **27/27**(CSR 自签经 `SecKeyVerifySignature` 验 + SPKI/alg OID 逐字节 + PKCS#10 三元素结构 + enroll 请求映射 + save→loadIdentity roundtrip + 既有 mTLS responder 测试仍过)。**跨语言金标准**:Swift 生成的真 CSR 喂给真 control-plane `verifyCsrPoPEc`**ok=true**(91 字节 P-256 SPKI)。SourceKit 曾误报"cannot find type"——已证伪(build 干净);`ZZEmitCSRCrossCheck.swift` 是一次性交叉验证临时文件,已删(仅剩 .build 索引残留)。
- **交叉验证**: swift + security 两评审 verdict warn,**0 critical/high**;security 评审**正向确认属性 15 成立**(私钥不可导出/bearer 仅用于 enroll 请求/PoP 是真 SE 签名/轮换干净替换/SE-优先回退)。findings 全 medium/low 记 #12(2 处 `as!` 强转、corrupt-record、**App 层 enroll UI 注入 + URLSessionHTTPTransport 适配**)。**真机 SE keygen + SecIdentity roundtrip 需物理设备 + Keychain entitlement → 设备验收**。**阻塞**: 无。
#### [x] B5 — 两服务安装 + BIND_HOST loopback S-GATE + `pair --install`(FIX C-host-1/M-host-2service/L-host-zone;2026-07-08)
- **交付**(agent 包,复用模块全 import 不改)。① **BIND_HOST S-GATE(FIX C-host-1,CRITICAL)**:`normalizeBindHost()`+`BindHostError`;缺省→127.0.0.1,非 loopback(0.0.0.0/LAN IP/::)抛;在 `buildInstallOptions`(读 env)与 `installService`(任何 write 前)双重强制,拒绝的安装零输出。② **两个 unit(FIX M-host-2service)**:参数化 launchd/systemd(label/ExecStart/programArguments/description),`installService` 出 base-app(`node dist/server.js` + base-app env)与 agent(`<bin> run` 监督 frpc、无 base-app env),`uninstallService` 双拆。③ **zone terminal(FIX L-host-zone)**:`assertNativeZone()`,base-app origin `https://<sub>.terminal.<domain>`(复用 originConfig,不削弱)。④ **cli `pair <CODE> --install`**:generateP256Identity(FIX H-host-2)→saveIdentity→enrollNative(P-256 CSR + POST /enroll)→provisionFrpc(B3)→writeFrpcConfig(buildNativeFrpcToml)→installService(双 unit)→打印 URL;legacy `pair`(无 --install)不变。
- **交叉验证抓到并修复 HIGH×2(loopback 守卫绕过,同一 bug class)**:`isLoopbackBindHost`(install.ts)与**姊妹 anti-SSRF 守卫** `isLoopbackWsUrl`(agentConfig.ts)都用 `startsWith('127.')``127.0.0.1.attacker.example.com`/`127.evil.net` 被当 loopback(Node 会 DNS 解析再 bind → 正是 S-GATE 要防的绕过)。自动修抽出共享 `agent/src/net/loopbackLiteral.ts`(`node:net isIPv4` 要求整串是 dotted-quad + 首段 127,或 localhost/::1/[::1],余皆拒),两处守卫同修。
- **验证(orchestrator 独立 + 亲审)**: 亲读 `loopbackLiteral.ts` 确认严格 fail-closed;`grep` 确认绕过回归测试在(`normalizeBindHost('127.0.0.1.attacker.example.com')`/`127.evil.net` 抛 + 专门 loopbackLiteral.test.ts)。**agent 全套 `npx vitest run` → 30 files / 214 passed**;`tsc --noEmit` exit 0;S-GATE 负例确认 0 写 0 运行。
- **偏差/延后**: 原生 `run` frpc 监督 = B4/H4(未建,记 **#13**);keystore.loadIdentity P-256 分支(记 #13);frpcBinary 占位 sha256(#11.9);systemd `Environment=` 注入守卫一致性(MEDIUM)+ BASE_APP_ENV_KEYS 漏 SCROLLBACK_BYTES/MAX_PAYLOAD_BYTES(LOW)+ cli 若干 minor 记入 backlog。**阻塞**: 无(B5 本体完成;端到端 host 隧道待 #13)。
#### [x] A6-min — POST /renew(host)+ /device/:id/renew + rotate.ts 真 X.509(FIX H-host-3;2026-07-08)
- **交付**: ① `ca/rotate.ts`(UPGRADE)弃 DEV JSON 占位,`createLeafRenewer` 委托 P-256 签发器(frpclient-issue/device-issue → assembleCertificate,sigAlg ecdsa-p256,CA 签在 KMS 后)出真 X.509;`renewHostLeaf(hostId, subjectPubkey, csr)` + `renewDeviceLeaf(deviceId, csr)``RenewedLeaf{cert,caChain,notAfter}`(notAfter 从 cert 读回)。② `api/renew.ts`(NEW)fastify 插件:POST /renew(host)+ POST /device/:id/renew,**仅由当前 mTLS 客户端证书认证**(可注入 PresentedClientCert seam,默认从 x-client-cert 读 base64-DER);身份(accountId + subdomain/deviceId + subject pubkey)靠**重解析当前证书的 SPIFFE SAN**(relay-auth parseSpiffeId),**绝不取自 body**(body 仅 {csr})。**anti-smuggling(A4 教训)**:重签 SAN 从注册表记录取同一 subdomain;把当前证书 pubkey 作 subject key 使委托门控强制 MVP 同密钥(不换钥)。每身份滑窗限流。uniform:无/坏证书 401、撤销/未知/账户或 deviceId 或密钥不符 403、换钥/坏 CSR 400、超限 429、成功 201。
- **交叉验证抓到并修复 HIGH**: `renewDeviceLeaf` 曾每次复用**原始** notAfter(读静态 DeviceRecord.notAfter,enroll 时设一次)→ 续期不延期、最终发已过期证书。自动修:续期先让注册表对 active 设备重算+持久化 `now()+ttl` 再签,notAfter 从发出的 cert 读回。
- **验证(orchestrator 独立 + 亲查修复)**: 亲读 rotate.ts 确认 device 续期先延期;**统一 control-plane `npx vitest run` → 26 files / 224 passed**;`tsc --noEmit` exit 0;rotate.test 升级为断言真 re-parse+CA 验签的 X.509(host+device 同 subdomain、换钥拒、撤销拒、no-smuggling),renew.test 13 路。
- **延后(记 #14,上线前必办)**: buildRenewRouter 未接 main.ts;**部署安全:nginx 必须由 $ssl_client_cert 设 x-client-cert 且 strip 客户端自带的**(否则头可伪造身份);当前证书链/过期校验 + "过期证书→拒" 测试;rate-limiter Map 有界化;csr 加 max 长度。**阻塞**: 无。
#### [x] B4/H4 — 原生 run-loop(frpc 监督 + loopback health)+ keystore P-256 loadIdentity(2026-07-08)
- **交付**(agent 包)。① `keys/keystore.ts` loadIdentity 按存储密钥 alg 分支(`createPrivateKey(pem).asymmetricKeyType`:ed25519→原路径逐字节不变;ec→p256IdentityFromPrivatePem)——存的 P-256 frp-client 身份能 round-trip(alg:p256 + 可用 ECDSA 签名钥)。② `health/probe.ts`(NEW):4 个可注入 seam 子检查(frpc alive、base-app loopback GET http://127.0.0.1:PORT、frpc 'start proxy success' 日志扫描、cert 未近过期 8h 窗)、`renderHealthStatus` 只印非敏感标识(subdomain/host id/过期 ISO/布尔,绝无 key/cert/token/CSR,INV9)、`startHealthMonitor`。③ `transport/frpSupervise.ts`(NEW——计划标 REUSE 但从未存在):spawn frpc + backoff 重启(1s..cap30s,稳定 60s 后 reset),IO 全注入。④ `cli.ts` run 分支:P-256 身份 + 写好的 frpc.toml → superviseFrpc;否则 legacy runTunnel(保留)。
- **交叉验证抓到并修复 HIGH**: `readFrpcLog``<stateDir>/frpc.log` 但**无人写它** → 恒返 '' → HealthReport.healthy **永不为真**(H4 监控形同虚设);且 frpc 子进程 stdio pipe 不排空 → 缓冲填满致 write(2) 阻塞挂起。自动修:`createFileLoggingSpawn` 把 frpc stdout/stderr tee 进持久化(每次 spawn 截断)日志,同时排空管道(一并解决挂起 MEDIUM)。
- **验证(orchestrator 独立)**: agent 全套 **247/247**(29+ 新:probe 19、frpSupervise 5、keystore/cli),tsc 干净;keystore P-256 round-trip 断言(alg + 原 pubkey 验签);status 无敏感泄漏断言(无 PRIVATE KEY/CERTIFICATE/token/csr)。
- **偏差/延后**: 真 frpc 二进制运行仍待 B3 tar.gz 解压(#11);frpc-stdout→log 已由 tee 打通(生产可用);keystore 仅按 asymmetricKeyType='ec' 分支未细究 namedCurve(LOW,记 #11);cli args.code! 无 runCli 内守卫(LOW)。**阻塞**: 无。
### 🤖 ANDROID CLIENT — 与 iOS 对齐的原生客户端(计划 + 地基已落;2026-07-08)
- **计划**: [ANDROID_CLIENT_PLAN.md](./ANDROID_CLIENT_PLAN.md)(多 agent 探索 iOS→综合→4 视角评审→终版)。镜像 iOS 的 SPM 包为 Gradle 模块;36 任务 AW0AW6。三个开放问题已定:R2(Termux terminal-view/emulator 是 **Apache-2.0** + JitPack,非 GPL-阻塞,独立验证更正)、R1(接受 FCM 尽力而为后台送达)、R7(服务端用 `google-auth-library`)。
- **本机环境约束**: JDK+Gradle 可用,但 **Android SDK 为空** → 仅**纯 Kotlin/JVM 模块**能真构建验证;Android 框架层(`:app`/`:terminal-view`/UI/FCM)只能写、待有 SDK 的机器构建。

View File

@@ -0,0 +1,181 @@
import Foundation
/// C-iOS · Manual DER encoder for a P-256 PKCS#10 `CertificationRequest`.
///
/// Built by hand (no CryptoKit / SecCertificate helpers) so the exact bytes are
/// under our control and the request is signed with a Secure-Enclave `SecKey`
/// via `SecKeyCreateSignature(.ecdsaSignatureMessageX962SHA256)` see the
/// `[FIX C-native, ClientTLS trap]` note on `SecureEnclaveKey`.
///
/// The output must satisfy the control-plane `verifyCsrPoPEc` (A1): an EC P-256
/// `SubjectPublicKeyInfo` (algorithm `id-ecPublicKey` + namedCurve `prime256v1`),
/// a self-signature under `ecdsa-with-SHA256`, and a valid PoP. Encoding is
/// strictly canonical DER (minimal lengths) so the server's re-serialization of
/// `CertificationRequestInfo` matches the bytes we signed.
///
/// ```
/// CertificationRequest ::= SEQUENCE {
/// certificationRequestInfo CertificationRequestInfo,
/// signatureAlgorithm AlgorithmIdentifier, -- ecdsa-with-SHA256
/// signature BIT STRING } -- X9.62 DER ECDSA-Sig
///
/// CertificationRequestInfo ::= SEQUENCE {
/// version INTEGER { v1(0) },
/// subject Name,
/// subjectPKInfo SubjectPublicKeyInfo,
/// attributes [0] IMPLICIT SET OF Attribute } -- empty
/// ```
public enum CertificateSigningRequest {
public enum CSRError: Error, Equatable, Sendable {
/// The public key was not the expected 65-byte X9.63 uncompressed point.
case invalidPublicKey
/// The empty subject CN is not encodable.
case invalidSubject
}
/// P-256 uncompressed public point is `0x04 || X(32) || Y(32)` = 65 bytes.
private static let uncompressedP256PointLength = 65
/// Build and self-sign a P-256 PKCS#10 CSR DER for `signer`'s key.
///
/// - Parameters:
/// - subjectCommonName: the CSR subject CN. The device leaf's identity is
/// driven server-side by the ownership-verified subdomain SAN, so this is
/// descriptive only; it must be non-empty.
/// - signer: the P-256 hardware key that provides the public key and signs
/// the `CertificationRequestInfo`.
public static func der(
subjectCommonName: String, signer: any P256HardwareKey
) throws -> Data {
guard !subjectCommonName.isEmpty else { throw CSRError.invalidSubject }
let publicPoint = [UInt8](try signer.publicKeyX963())
guard publicPoint.count == uncompressedP256PointLength, publicPoint[0] == 0x04 else {
throw CSRError.invalidPublicKey
}
let requestInfo = certificationRequestInfo(
subjectCommonName: subjectCommonName, publicPoint: publicPoint
)
let signature = [UInt8](try signer.sign(Data(requestInfo)))
let request = DERWriter.sequence([
requestInfo,
ecdsaWithSHA256AlgorithmIdentifier,
DERWriter.bitString(signature),
])
return Data(request)
}
// MARK: - CertificationRequestInfo
private static func certificationRequestInfo(
subjectCommonName: String, publicPoint: [UInt8]
) -> [UInt8] {
DERWriter.sequence([
DERWriter.integer0, // version v1(0)
name(commonName: subjectCommonName),
subjectPublicKeyInfo(publicPoint: publicPoint),
DERWriter.emptyAttributesContext0, // [0] IMPLICIT SET OF Attribute (empty)
])
}
/// `Name ::= SEQUENCE OF RelativeDistinguishedName` with a single CN RDN.
private static func name(commonName: String) -> [UInt8] {
let attribute = DERWriter.sequence([
DERWriter.oid(OID.commonName),
DERWriter.utf8String(commonName),
])
let rdn = DERWriter.set([attribute])
return DERWriter.sequence([rdn])
}
/// `SubjectPublicKeyInfo` for an EC P-256 key: `id-ecPublicKey` +
/// `prime256v1` named curve, then the uncompressed point as a BIT STRING.
private static func subjectPublicKeyInfo(publicPoint: [UInt8]) -> [UInt8] {
let algorithm = DERWriter.sequence([
DERWriter.oid(OID.ecPublicKey),
DERWriter.oid(OID.prime256v1),
])
return DERWriter.sequence([
algorithm,
DERWriter.bitString(publicPoint),
])
}
/// `AlgorithmIdentifier` for `ecdsa-with-SHA256` no parameters (absent, per
/// RFC 5758), which is exactly what the server's verifier expects.
private static let ecdsaWithSHA256AlgorithmIdentifier: [UInt8] =
DERWriter.sequence([DERWriter.oid(OID.ecdsaWithSHA256)])
}
// MARK: - Object identifiers (DER content bytes, tag/length added by DERWriter.oid)
private enum OID {
/// 1.2.840.10045.2.1 id-ecPublicKey.
static let ecPublicKey: [UInt8] = [0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01]
/// 1.2.840.10045.3.1.7 prime256v1 / secp256r1.
static let prime256v1: [UInt8] = [0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07]
/// 1.2.840.10045.4.3.2 ecdsa-with-SHA256.
static let ecdsaWithSHA256: [UInt8] = [0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x04, 0x03, 0x02]
/// 2.5.4.3 id-at-commonName.
static let commonName: [UInt8] = [0x55, 0x04, 0x03]
}
// MARK: - Minimal canonical DER writer
/// A tiny DER encoder. Every helper returns a fully-formed TLV so callers just
/// concatenate children canonical minimal-length encoding throughout.
enum DERWriter {
private static let tagInteger: UInt8 = 0x02
private static let tagBitString: UInt8 = 0x03
private static let tagOID: UInt8 = 0x06
private static let tagUTF8String: UInt8 = 0x0C
private static let tagSequence: UInt8 = 0x30
private static let tagSet: UInt8 = 0x31
private static let tagContext0Constructed: UInt8 = 0xA0
/// `INTEGER 0` the fixed PKCS#10 version v1(0).
static let integer0: [UInt8] = [tagInteger, 0x01, 0x00]
/// `[0] IMPLICIT SET OF Attribute`, empty `A0 00`.
static let emptyAttributesContext0: [UInt8] = [tagContext0Constructed, 0x00]
static func sequence(_ children: [[UInt8]]) -> [UInt8] {
tlv(tagSequence, children.flatMap { $0 })
}
static func set(_ children: [[UInt8]]) -> [UInt8] {
tlv(tagSet, children.flatMap { $0 })
}
static func oid(_ content: [UInt8]) -> [UInt8] {
tlv(tagOID, content)
}
static func utf8String(_ value: String) -> [UInt8] {
tlv(tagUTF8String, [UInt8](Data(value.utf8)))
}
/// BIT STRING with zero unused bits (all our bit strings are byte-aligned).
static func bitString(_ content: [UInt8]) -> [UInt8] {
tlv(tagBitString, [0x00] + content)
}
/// Tag-Length-Value with canonical DER length encoding.
private static func tlv(_ tag: UInt8, _ value: [UInt8]) -> [UInt8] {
[tag] + length(value.count) + value
}
/// DER length: short form (<128) or long form (0x80 | byteCount, big-endian).
private static func length(_ count: Int) -> [UInt8] {
if count < 0x80 { return [UInt8(count)] }
var value = count
var bytes: [UInt8] = []
while value > 0 {
bytes.insert(UInt8(value & 0xFF), at: 0)
value >>= 8
}
return [0x80 | UInt8(bytes.count)] + bytes
}
}

View File

@@ -0,0 +1,210 @@
import Foundation
/// C-iOS · Talks to the control-plane device-enrollment API (A4):
///
/// `POST /device/enroll` [Bearer device:enroll]
/// body { csr, keyAlg:'ec-p256', subdomain, deviceName, attestation? }
/// 201 { deviceId, cert, caChain, notBefore, notAfter, renewAfter }
///
/// `POST /device/attest/challenge` [Bearer device:enroll] { challenge, expires_in }
/// `POST /device/:id/renew` [Bearer device:enroll] (A6 seam)
///
/// Deliberately logic-free about TLS: it only builds requests and maps responses.
/// The `EnrollmentTransport` seam (same `send(_:)` shape as the app's
/// `HTTPTransport`) lets the App inject `URLSessionHTTPTransport` in production
/// and a stub in tests. The `csr` is sent as standard base64(DER), which the
/// server's `decodeCsrWire` accepts directly.
public struct DeviceEnrollmentClient: Sendable {
private let baseURL: URL
/// The one-time account `device:enroll` bearer obtained at login.
private let bearerToken: String
private let transport: any EnrollmentTransport
public init(baseURL: URL, bearerToken: String, transport: any EnrollmentTransport) {
self.baseURL = baseURL
self.bearerToken = bearerToken
self.transport = transport
}
/// Enroll a freshly-generated hardware key: POST the CSR, receive the leaf.
public func enroll(
csrDER: Data, subdomain: String, deviceName: String, attestation: String? = nil
) async throws -> EnrollmentResult {
var body: [String: String] = [
"csr": csrDER.base64EncodedString(),
"keyAlg": "ec-p256",
"subdomain": subdomain,
"deviceName": deviceName,
]
if let attestation { body["attestation"] = attestation }
let request = try makeJSONRequest(path: "/device/enroll", jsonObject: body)
return try await sendExpectingLeaf(request)
}
/// Renew against the SAME hardware key (A6 seam): a fresh CSR to
/// `/device/:id/renew`. Server support lands in A6; the client shape is here
/// so the rotation scheduler has an endpoint to drive.
public func renew(deviceId: String, csrDER: Data) async throws -> EnrollmentResult {
let body = ["csr": csrDER.base64EncodedString(), "keyAlg": "ec-p256"]
let path = "/device/\(deviceId)/renew"
let request = try makeJSONRequest(path: path, jsonObject: body)
return try await sendExpectingLeaf(request)
}
/// Fetch a short-TTL attestation challenge (stub server-side; shapes the
/// keygenattestCSR flow so App Attest can layer in later build order C·4).
public func attestChallenge() async throws -> AttestChallenge {
let request = try makeJSONRequest(path: "/device/attest/challenge", jsonObject: [:])
let (data, response) = try await transport.send(request)
guard response.statusCode == 200 else {
throw DeviceEnrollmentError.http(status: response.statusCode, code: errorCode(in: data))
}
guard let dto = try? JSONDecoder().decode(AttestChallengeDTO.self, from: data) else {
throw DeviceEnrollmentError.malformedResponse
}
return AttestChallenge(challenge: dto.challenge, expiresIn: dto.expires_in)
}
// MARK: - Request/response plumbing
private func makeJSONRequest(path: String, jsonObject: [String: String]) throws -> URLRequest {
guard let url = URL(string: path, relativeTo: baseURL) else {
throw DeviceEnrollmentError.malformedResponse
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: jsonObject)
return request
}
private func sendExpectingLeaf(_ request: URLRequest) async throws -> EnrollmentResult {
let (data, response) = try await transport.send(request)
guard response.statusCode == 201 else {
throw DeviceEnrollmentError.http(status: response.statusCode, code: errorCode(in: data))
}
guard let dto = try? JSONDecoder().decode(EnrollResponseDTO.self, from: data) else {
throw DeviceEnrollmentError.malformedResponse
}
return try dto.toResult()
}
private func errorCode(in data: Data) -> String? {
(try? JSONDecoder().decode(ErrorDTO.self, from: data))?.error
}
}
// MARK: - Transport seam
/// The one exchange the enrollment client needs identical in shape to the
/// app's `HTTPTransport.send`, so the production `URLSessionHTTPTransport` slots
/// in via a one-line adapter and tests inject a stub. Kept local so `ClientTLS`
/// stays a leaf package (no dependency on the WireProtocol transport contract).
public protocol EnrollmentTransport: Sendable {
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse)
}
// MARK: - Results & errors
public struct EnrollmentResult: Equatable, Sendable {
public let deviceId: String
/// Leaf certificate DER (decoded from the response's base64).
public let certificate: Data
/// Issuer chain DERs (device-CA etc.), leaf excluded.
public let caChain: [Data]
public let notBefore: Date?
public let notAfter: Date?
/// When to renew from the same hardware key (~2/3 of the lifetime).
public let renewAfter: Date?
public init(
deviceId: String, certificate: Data, caChain: [Data],
notBefore: Date?, notAfter: Date?, renewAfter: Date?
) {
self.deviceId = deviceId
self.certificate = certificate
self.caChain = caChain
self.notBefore = notBefore
self.notAfter = notAfter
self.renewAfter = renewAfter
}
/// The rotation seam: is the leaf due for renewal as of `now`?
/// A missing `renewAfter` never triggers (fail-safe the TLS stack is the
/// real gate; the scheduler only pre-empts expiry).
public func isRenewalDue(asOf now: Date = Date()) -> Bool {
guard let renewAfter else { return false }
return now >= renewAfter
}
}
public struct AttestChallenge: Equatable, Sendable {
public let challenge: String
public let expiresIn: Int
}
public enum DeviceEnrollmentError: Error, Equatable, Sendable {
/// Non-success HTTP status with the server's uniform `{ error }` code, if any
/// (401 missing/rejected token, 403 subdomain-not-owned, 429 rate_limited,
/// 400 rejected CSR/subdomain).
case http(status: Int, code: String?)
/// 2xx body that did not decode to the expected shape.
case malformedResponse
}
// MARK: - Wire DTOs (base64 + ISO-8601 strings, mapped to typed values)
private struct EnrollResponseDTO: Decodable {
let deviceId: String
let cert: String
let caChain: [String]
let notBefore: String?
let notAfter: String?
let renewAfter: String?
func toResult() throws -> EnrollmentResult {
guard let certificate = Data(base64Encoded: cert) else {
throw DeviceEnrollmentError.malformedResponse
}
let chain = try caChain.map { entry -> Data in
guard let der = Data(base64Encoded: entry) else {
throw DeviceEnrollmentError.malformedResponse
}
return der
}
return EnrollmentResult(
deviceId: deviceId,
certificate: certificate,
caChain: chain,
notBefore: ISO8601.date(notBefore),
notAfter: ISO8601.date(notAfter),
renewAfter: ISO8601.date(renewAfter)
)
}
}
private struct AttestChallengeDTO: Decodable {
let challenge: String
let expires_in: Int // swiftlint:disable:this identifier_name wire field name
}
private struct ErrorDTO: Decodable {
let error: String
}
/// The server emits `Date.toISOString()` (fractional-second UTC). Parse with and
/// without fractional seconds so both `...T00:00:00.000Z` and `...T00:00:00Z`
/// decode; an unparseable/absent value degrades to `nil` (dates are advisory).
private enum ISO8601 {
static func date(_ text: String?) -> Date? {
guard let text else { return nil }
// Formatters are created per call: ISO8601DateFormatter is not Sendable,
// so it cannot be a shared static under Swift 6 strict concurrency. Date
// parsing here is rare (once per enroll/renew), so the cost is immaterial.
let withFractional = ISO8601DateFormatter()
withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = withFractional.date(from: text) { return date }
return ISO8601DateFormatter().date(from: text)
}
}

View File

@@ -50,6 +50,18 @@ private struct StoredP12Blob: Codable {
let passphrase: String
}
/// C-iOS · The enrolled-identity record stored beside the Secure-Enclave leaf:
/// the issuer chain (presented on the handshake), the `deviceId` (drives renew),
/// and the rotation timing. The private key itself never lives here it stays
/// non-exportable in the Secure Enclave.
private struct StoredEnrollment: Codable {
let deviceId: String
let deviceName: String
let caChain: [Data]
let notAfter: Date?
let renewAfter: Date?
}
/// Keychain-backed store: one `kSecClassGenericPassword` item holding the
/// JSON-encoded `StoredP12Blob` in `kSecValueData`, protected with
/// `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` (available after the first
@@ -77,6 +89,10 @@ public struct KeychainClientIdentityStore: ClientIdentityStore {
}
public func loadIdentity() throws -> ClientIdentity? {
// C-iOS · Prefer the Secure-Enclave-backed enrolled identity (the .p12-free
// path). During the dual-trust migration window a device may still carry a
// legacy imported `.p12`; fall back to it so existing installs keep working.
if let enrolled = try loadDeviceIdentity() { return enrolled }
guard let blob = try readBlob() else { return nil }
return try PKCS12Importer.importIdentity(
data: blob.p12, passphrase: blob.passphrase
@@ -88,6 +104,9 @@ public struct KeychainClientIdentityStore: ClientIdentityStore {
}
public func remove() throws {
// Clear BOTH paths so removal is unconditional: the SE key + enrolled leaf
// and the legacy `.p12` blob.
try removeEnrolled()
let status = SecItemDelete(baseQuery() as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw ClientIdentityStoreError.keychain(status)
@@ -95,12 +114,196 @@ public struct KeychainClientIdentityStore: ClientIdentityStore {
}
public func hasInstalledIdentity() -> Bool {
if hasEnrolledLeaf() { return true }
var query = baseQuery()
query[kSecReturnData as String] = false
query[kSecMatchLimit as String] = kSecMatchLimitOne
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
}
// MARK: - C-iOS · Secure-Enclave enrollment (the .p12-free path)
/// Keychain tag of the device's Secure-Enclave private key. The enrolled leaf
/// binds to this key so `SecItemCopyMatching(kSecClassIdentity)` can assemble
/// the `SecIdentity` presented on the existing mTLS path (unchanged).
private var deviceKeyTag: Data { Data("\(service).device-key".utf8) }
/// Label under which the enrolled leaf certificate is stored.
private var leafLabel: String { "\(service).device-leaf" }
/// Account of the generic-password item holding the enrollment record
/// (deviceId + issuer chain + rotation timing) alongside the leaf.
private var enrollmentAccount: String { "\(account).enrollment" }
/// One-time enrollment: generate a NON-EXPORTABLE Secure-Enclave P-256 key,
/// self-sign a CSR with it, POST it, and store the returned leaf against that
/// key. Returns the installed cert's summary. `keyProvider` is injectable so
/// non-device builds can supply a software key; production defaults to the SE.
public func enroll(
using client: DeviceEnrollmentClient,
subdomain: String,
deviceName: String,
keyProvider: (@Sendable () throws -> any P256HardwareKey)? = nil
) async throws -> ClientCertificateSummary? {
let key = try keyProvider?()
?? SecureEnclaveKeyFactory.generateSecureEnclave(tag: deviceKeyTag)
let csr = try CertificateSigningRequest.der(subjectCommonName: deviceName, signer: key)
let result = try await client.enroll(
csrDER: csr, subdomain: subdomain, deviceName: deviceName
)
try storeEnrolledLeaf(result, deviceName: deviceName)
return try loadSummary()
}
/// Rotation: re-CSR from the SAME Secure-Enclave key and replace the leaf.
/// The caller (rotation scheduler) tears down live connections afterward so
/// the new cert is presented on the next handshake (plan §3.3).
public func renew(
using client: DeviceEnrollmentClient
) async throws -> ClientCertificateSummary? {
guard let record = try readEnrollment(),
let key = try SecureEnclaveKeyFactory.load(tag: deviceKeyTag)
else {
throw ClientIdentityStoreError.corruptStoredBlob // nothing to renew
}
let csr = try CertificateSigningRequest.der(
subjectCommonName: record.deviceName, signer: key
)
let result = try await client.renew(deviceId: record.deviceId, csrDER: csr)
try storeEnrolledLeaf(result, deviceName: record.deviceName)
return try loadSummary()
}
/// The Secure-Enclave-backed identity: the assembled `SecIdentity` (leaf bound
/// to the SE key) plus the stored issuer chain. `nil` when not enrolled.
func loadDeviceIdentity() throws -> ClientIdentity? {
let query: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrApplicationTag as String: deviceKeyTag,
kSecReturnRef as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let value = result else {
throw ClientIdentityStoreError.keychain(status)
}
// Safe: the query pins kSecClassIdentity, so a match is a SecIdentity.
let identity = value as! SecIdentity
let issuers = ((try? readEnrollment())?.caChain ?? []).compactMap {
SecCertificateCreateWithData(nil, $0 as CFData)
}
return ClientIdentity(secIdentity: identity, issuerCertificates: issuers)
}
/// Cheap check: is a Secure-Enclave identity installed? (No cert re-parse.)
private func hasEnrolledLeaf() -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrApplicationTag as String: deviceKeyTag,
kSecMatchLimit as String: kSecMatchLimitOne,
]
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
}
/// Persist the enrolled leaf (delete-then-add so rotation replaces the prior
/// one) plus the enrollment record. The leaf binds to the permanent SE key,
/// letting the keychain form the identity on load.
private func storeEnrolledLeaf(_ result: EnrollmentResult, deviceName: String) throws {
guard let certificate = SecCertificateCreateWithData(nil, result.certificate as CFData)
else {
throw ClientIdentityStoreError.corruptStoredBlob // not a valid DER cert
}
let deleteLeaf: [String: Any] = [
kSecClass as String: kSecClassCertificate,
kSecAttrLabel as String: leafLabel,
]
let deleteStatus = SecItemDelete(deleteLeaf as CFDictionary)
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
throw ClientIdentityStoreError.keychain(deleteStatus)
}
let addLeaf: [String: Any] = [
kSecClass as String: kSecClassCertificate,
kSecValueRef as String: certificate,
kSecAttrLabel as String: leafLabel,
]
let addStatus = SecItemAdd(addLeaf as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw ClientIdentityStoreError.keychain(addStatus)
}
try writeEnrollment(
StoredEnrollment(
deviceId: result.deviceId,
deviceName: deviceName,
caChain: result.caChain,
notAfter: result.notAfter,
renewAfter: result.renewAfter
)
)
}
/// Delete the SE key, the enrolled leaf, and the enrollment record. Idempotent.
private func removeEnrolled() throws {
let deleteLeaf: [String: Any] = [
kSecClass as String: kSecClassCertificate,
kSecAttrLabel as String: leafLabel,
]
let leafStatus = SecItemDelete(deleteLeaf as CFDictionary)
guard leafStatus == errSecSuccess || leafStatus == errSecItemNotFound else {
throw ClientIdentityStoreError.keychain(leafStatus)
}
try SecureEnclaveKeyFactory.delete(tag: deviceKeyTag)
let recordStatus = SecItemDelete(enrollmentQuery() as CFDictionary)
guard recordStatus == errSecSuccess || recordStatus == errSecItemNotFound else {
throw ClientIdentityStoreError.keychain(recordStatus)
}
}
private func enrollmentQuery() -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: enrollmentAccount,
]
}
private func writeEnrollment(_ record: StoredEnrollment) throws {
let data: Data
do {
data = try JSONEncoder().encode(record)
} catch {
throw ClientIdentityStoreError.corruptStoredBlob
}
let deleteStatus = SecItemDelete(enrollmentQuery() as CFDictionary)
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
throw ClientIdentityStoreError.keychain(deleteStatus)
}
var attributes = enrollmentQuery()
attributes[kSecValueData as String] = data
attributes[kSecAttrAccessible as String] =
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let addStatus = SecItemAdd(attributes as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw ClientIdentityStoreError.keychain(addStatus)
}
}
private func readEnrollment() throws -> StoredEnrollment? {
var query = enrollmentQuery()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let data = result as? Data else {
throw ClientIdentityStoreError.keychain(status)
}
do {
return try JSONDecoder().decode(StoredEnrollment.self, from: data)
} catch {
throw ClientIdentityStoreError.corruptStoredBlob
}
}
// MARK: - Keychain plumbing
private func baseQuery() -> [String: Any] {

View File

@@ -0,0 +1,200 @@
import Foundation
import os
import Security
/// C-iOS · A P-256 signing key that lives ENTIRELY inside SecKey /
/// Security.framework never CryptoKit.
///
/// **`[FIX C-native, ClientTLS trap]`** the enrollment path deliberately avoids
/// `CryptoKit.SecureEnclave.P256.Signing.PrivateKey` and `SecKeyCreateWithData`
/// over a Secure-Enclave token: either route mints a key the keychain cannot
/// later match to a stored certificate, so `SecItemCopyMatching(kSecClassIdentity)`
/// fails with `errSecItemNotFound (-25300)` and the whole mTLS identity silently
/// never assembles. Staying on `SecKeyCreateRandomKey` + `SecKeyCreateSignature`
/// keeps the private key a first-class, permanent keychain resident that the leaf
/// certificate binds to automatically.
public protocol P256HardwareKey: Sendable {
/// The public key in ANSI X9.63 uncompressed form: `0x04 || X || Y`
/// (65 bytes for P-256). This is exactly what wraps into a SubjectPublicKeyInfo.
func publicKeyX963() throws -> Data
/// ECDSA sign `message` over SHA-256, returning the X9.62 DER signature
/// (`SEQUENCE { r INTEGER, s INTEGER }`) the exact shape a PKCS#10
/// `signature` BIT STRING and `verifyCsrPoPEc` expect. The digest is computed
/// by the algorithm (`.ecdsaSignatureMessageX962SHA256`), so callers pass the
/// raw message (the DER of `CertificationRequestInfo`), NOT a pre-hash.
func sign(_ message: Data) throws -> Data
}
public enum SecureEnclaveKeyError: Error, Equatable, Sendable {
/// The Secure Enclave is absent (Simulator) or the app lacks the entitlement.
/// Carries the underlying `SecKeyCreateRandomKey` failure for diagnostics.
case secureEnclaveUnavailable(String)
/// Key generation failed for a reason other than SE-unavailability.
case keyGenerationFailed(String)
/// The public key could not be derived from the private key.
case publicKeyUnavailable
/// Exporting the public key to X9.63 bytes failed.
case exportFailed(String)
/// Signing failed (algorithm unsupported, user-presence denied, ).
case signatureFailed(String)
/// A keychain `SecItem*` lookup/delete failed with this status.
case keychain(OSStatus)
}
/// A `SecKey`-backed P-256 key. The wrapped `SecKey` is a Secure-Enclave key in
/// production (via `SecureEnclaveKeyFactory.generate`) and an in-process software
/// key on Simulator/tests (via `.generateSoftware`) both drive the SAME
/// `SecKeyCreateSignature` path, so the CSR encoder is exercised identically.
///
/// `@unchecked Sendable`: `SecKey` is an immutable, thread-safe CoreFoundation
/// handle once created; this wrapper only ever reads it.
public final class SecureEnclaveKey: P256HardwareKey, @unchecked Sendable {
/// The (non-exportable, in production) private key. Never leaves the device.
private let privateKey: SecKey
/// Wrap an existing `SecKey`. Public so tests can inject a software P-256 key
/// created via `SecKeyCreateRandomKey` without the SE token.
public init(privateKey: SecKey) {
self.privateKey = privateKey
}
public func publicKeyX963() throws -> Data {
guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
throw SecureEnclaveKeyError.publicKeyUnavailable
}
var error: Unmanaged<CFError>?
guard let data = SecKeyCopyExternalRepresentation(publicKey, &error) as Data? else {
throw SecureEnclaveKeyError.exportFailed(Self.describe(error))
}
return data
}
public func sign(_ message: Data) throws -> Data {
var error: Unmanaged<CFError>?
guard
let signature = SecKeyCreateSignature(
privateKey,
.ecdsaSignatureMessageX962SHA256,
message as CFData,
&error
) as Data?
else {
throw SecureEnclaveKeyError.signatureFailed(Self.describe(error))
}
return signature
}
static func describe(_ error: Unmanaged<CFError>?) -> String {
guard let error else { return "unknown" }
return String(describing: error.takeRetainedValue())
}
}
/// Creates / loads / deletes the device's P-256 key.
///
/// Production generation is **Secure-Enclave, non-exportable, permanent** so the
/// private key never leaves hardware and survives relaunch as a keychain
/// resident. `generateSoftware` is the documented **non-device fallback** (the SE
/// is unavailable on the Simulator and per plan §3.3 desktop is explicitly
/// downgraded to a best-effort OS-keychain key); it is the SAME `SecKey` API
/// minus the SE token, so the CSR/signature code path is unchanged.
public enum SecureEnclaveKeyFactory {
private static let log = Logger(subsystem: "com.yaojia.webterm", category: "se-key")
/// Generate a NON-EXPORTABLE P-256 key inside the Secure Enclave, marked
/// permanent + tagged so the keychain can later bind the enrolled leaf to it.
/// Throws `.secureEnclaveUnavailable` on Simulator / missing entitlement so
/// callers can fall back to `generateSoftware` for non-device builds.
public static func generateSecureEnclave(tag: Data) throws -> SecureEnclaveKey {
try generate(tag: tag, inSecureEnclave: true, permanent: true)
}
/// Software P-256 key (NO Secure Enclave token). Simulator / desktop
/// best-effort / unit tests. `permanent == false` keeps it in-process only.
public static func generateSoftware(tag: Data? = nil, permanent: Bool = false) throws
-> SecureEnclaveKey {
try generate(tag: tag, inSecureEnclave: false, permanent: permanent)
}
private static func generate(
tag: Data?, inSecureEnclave: Bool, permanent: Bool
) throws -> SecureEnclaveKey {
var privateKeyAttrs: [String: Any] = [kSecAttrIsPermanent as String: permanent]
if let tag {
privateKeyAttrs[kSecAttrApplicationTag as String] = tag
}
if inSecureEnclave {
var accessError: Unmanaged<CFError>?
guard
let access = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
.privateKeyUsage,
&accessError
)
else {
throw SecureEnclaveKeyError.keyGenerationFailed(
"access control: \(SecureEnclaveKey.describe(accessError))"
)
}
privateKeyAttrs[kSecAttrAccessControl as String] = access
}
var attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecPrivateKeyAttrs as String: privateKeyAttrs,
]
if inSecureEnclave {
attributes[kSecAttrTokenID as String] = kSecAttrTokenIDSecureEnclave
}
var error: Unmanaged<CFError>?
guard let key = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
let description = SecureEnclaveKey.describe(error)
if inSecureEnclave {
log.error(
"Secure Enclave keygen failed (simulator / no entitlement?): \(description, privacy: .public)"
)
throw SecureEnclaveKeyError.secureEnclaveUnavailable(description)
}
throw SecureEnclaveKeyError.keyGenerationFailed(description)
}
return SecureEnclaveKey(privateKey: key)
}
/// Load a previously-generated key (SE or software) by its keychain tag.
/// `nil` if none exists (the normal pre-enroll state).
public static func load(tag: Data) throws -> SecureEnclaveKey? {
let query: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrApplicationTag as String: tag,
kSecReturnRef as String: true,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let value = result else {
throw SecureEnclaveKeyError.keychain(status)
}
// Safe: the query pins kSecClassKey, so a match is always a SecKey.
let key = value as! SecKey
return SecureEnclaveKey(privateKey: key)
}
/// Delete the device key by tag. Idempotent.
public static func delete(tag: Data) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrApplicationTag as String: tag,
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw SecureEnclaveKeyError.keychain(status)
}
}
}

View File

@@ -0,0 +1,188 @@
import Foundation
import Security
import Testing
@testable import ClientTLS
// C-iOS · Proves the manual PKCS#10 encoder produces a well-formed, self-signed
// P-256 CSR that the control-plane `verifyCsrPoPEc` (id-ecPublicKey + prime256v1
// SPKI, ecdsa-with-SHA256 self-signature) would accept. Runs headless with a
// SOFTWARE P-256 SecKey (SecKeyCreateRandomKey WITHOUT the Secure-Enclave token)
// so no hardware/entitlement is needed the signing path is byte-identical to
// the on-device SE key. Real SE keygen + SecIdentity roundtrip are device-only.
/// Software P-256 key via the SAME SecKey API used on-device (no SE token).
private func makeSoftwareKey() throws -> SecureEnclaveKey {
try SecureEnclaveKeyFactory.generateSoftware(tag: nil, permanent: false)
}
@Test("CSR is a canonical PKCS#10 SEQUENCE of exactly three elements")
func csrOuterStructure() throws {
// Arrange
let signer = try makeSoftwareKey()
// Act
let der = try CertificateSigningRequest.der(
subjectCommonName: "web-terminal-device", signer: signer
)
// Assert outer CertificationRequest ::= SEQUENCE { info, algId, sig }.
let bytes = [UInt8](der)
let outer = try #require(TestDER.read(bytes, at: 0))
#expect(outer.tag == 0x30)
#expect(outer.end == bytes.count) // no trailing garbage
let parts = TestDER.children(bytes, outer)
#expect(parts.count == 3)
#expect(parts[0].tag == 0x30) // certificationRequestInfo
#expect(parts[1].tag == 0x30) // signatureAlgorithm
#expect(parts[2].tag == 0x03) // signature BIT STRING
}
@Test("CSR self-signature verifies against the embedded P-256 public key")
func csrSelfSignatureVerifies() throws {
// Arrange
let signer = try makeSoftwareKey()
let expectedPoint = try signer.publicKeyX963()
// Act
let der = try CertificateSigningRequest.der(
subjectCommonName: "web-terminal-device", signer: signer
)
let bytes = [UInt8](der)
// Extract the exact CertificationRequestInfo bytes that were signed and the
// ECDSA signature (the same crypto check `verifyCsrPoPEc`'s req.verify() runs).
let outer = try #require(TestDER.read(bytes, at: 0))
let parts = TestDER.children(bytes, outer)
let infoBytes = Data(bytes[parts[0].start..<parts[0].end])
let sigContent = parts[2] // BIT STRING: first content byte is unused-bits (0x00)
let signature = Data(bytes[(sigContent.valueStart + 1)..<sigContent.valueEnd])
// Rebuild the public SecKey from the X9.63 point and verify.
let publicKey = try #require(makePublicKey(fromX963: expectedPoint))
var error: Unmanaged<CFError>?
let ok = SecKeyVerifySignature(
publicKey,
.ecdsaSignatureMessageX962SHA256,
infoBytes as CFData,
signature as CFData,
&error
)
#expect(ok, "self-signature must verify: \(String(describing: error?.takeRetainedValue()))")
}
@Test("CSR embeds a P-256 SubjectPublicKeyInfo the server verifier accepts")
func csrEmbedsP256Spki() throws {
// Arrange
let signer = try makeSoftwareKey()
let point = [UInt8](try signer.publicKeyX963())
// Act
let der = try CertificateSigningRequest.der(
subjectCommonName: "web-terminal-device", signer: signer
)
let bytes = [UInt8](der)
// certificationRequestInfo { version, subject, subjectPKInfo, [0] attrs }
let outer = try #require(TestDER.read(bytes, at: 0))
let info = TestDER.children(bytes, outer)[0]
let infoChildren = TestDER.children(bytes, info)
#expect(infoChildren.count == 4)
#expect(Array(bytes[infoChildren[0].start..<infoChildren[0].end]) == [0x02, 0x01, 0x00]) // v1(0)
#expect(infoChildren[3].tag == 0xA0) // [0] IMPLICIT attributes
#expect(infoChildren[3].valueEnd - infoChildren[3].valueStart == 0) // empty SET
// subjectPublicKeyInfo ::= SEQUENCE { AlgorithmIdentifier, BIT STRING point }
let spki = infoChildren[2]
let spkiChildren = TestDER.children(bytes, spki)
#expect(spkiChildren.count == 2)
let algIdChildren = TestDER.children(bytes, spkiChildren[0])
// AlgorithmIdentifier { id-ecPublicKey, prime256v1 } the exact OIDs
// verifyCsrPoPEc pins.
#expect(Array(bytes[algIdChildren[0].start..<algIdChildren[0].end])
== [0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01])
#expect(Array(bytes[algIdChildren[1].start..<algIdChildren[1].end])
== [0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07])
// BIT STRING content = 0x00 unused-bits + the exact 65-byte point.
let bitString = spkiChildren[1]
#expect(bitString.tag == 0x03)
#expect(bytes[bitString.valueStart] == 0x00)
#expect(Array(bytes[(bitString.valueStart + 1)..<bitString.valueEnd]) == point)
}
@Test("signatureAlgorithm is ecdsa-with-SHA256")
func csrSignatureAlgorithm() throws {
let signer = try makeSoftwareKey()
let der = try CertificateSigningRequest.der(
subjectCommonName: "web-terminal-device", signer: signer
)
let bytes = [UInt8](der)
let outer = try #require(TestDER.read(bytes, at: 0))
let algId = TestDER.children(bytes, outer)[1]
let oid = TestDER.children(bytes, algId)[0]
#expect(Array(bytes[oid.start..<oid.end])
== [0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x04, 0x03, 0x02])
}
@Test("empty subject CN is rejected")
func csrRejectsEmptySubject() throws {
let signer = try makeSoftwareKey()
#expect(throws: CertificateSigningRequest.CSRError.invalidSubject) {
_ = try CertificateSigningRequest.der(subjectCommonName: "", signer: signer)
}
}
// MARK: - helpers
/// Reconstruct a public SecKey from an X9.63 uncompressed point for verification.
private func makePublicKey(fromX963 point: Data) -> SecKey? {
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
kSecAttrKeySizeInBits as String: 256,
]
return SecKeyCreateWithData(point as CFData, attributes as CFDictionary, nil)
}
/// A throwaway DER reader for assertions (production parsing lives in
/// CertificateSummary's X509 walk; this mirrors it for tests).
enum TestDER {
struct Element {
let tag: UInt8
let start: Int // index of the tag byte
let valueStart: Int
let valueEnd: Int
var end: Int { valueEnd }
}
static func read(_ bytes: [UInt8], at start: Int) -> Element? {
guard start >= 0, start + 1 < bytes.count else { return nil }
let tag = bytes[start]
var index = start + 1
let first = bytes[index]
index += 1
var length = 0
if first & 0x80 == 0 {
length = Int(first)
} else {
let count = Int(first & 0x7F)
guard count > 0, count <= 4, index + count <= bytes.count else { return nil }
for _ in 0..<count {
length = (length << 8) | Int(bytes[index])
index += 1
}
}
let valueEnd = index + length
guard valueEnd <= bytes.count else { return nil }
return Element(tag: tag, start: start, valueStart: index, valueEnd: valueEnd)
}
static func children(_ bytes: [UInt8], _ parent: Element) -> [Element] {
var elements: [Element] = []
var index = parent.valueStart
while index < parent.valueEnd, let element = read(bytes, at: index) {
elements.append(element)
index = element.valueEnd
}
return elements
}
}

View File

@@ -0,0 +1,171 @@
import Foundation
import Testing
@testable import ClientTLS
// C-iOS · DeviceEnrollmentClient request-building + response-mapping, driven by a
// stub transport (no network). Mirrors the A4 contract exactly.
private let baseURL = URL(string: "https://cp.terminal.yaojia.wang")!
private let bearer = "device-enroll-token-abc"
/// Records the last request and replays a canned response. `@unchecked Sendable`:
/// the mutable capture is guarded by a lock.
private final class StubTransport: EnrollmentTransport, @unchecked Sendable {
private let lock = NSLock()
private var _lastRequest: URLRequest?
private let status: Int
private let body: Data
init(status: Int, body: Data) {
self.status = status
self.body = body
}
var lastRequest: URLRequest? { lock.withLock { _lastRequest } }
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
lock.withLock { _lastRequest = request }
let response = HTTPURLResponse(
url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil
)!
return (body, response)
}
}
private func enrollBody(
deviceId: String = "dev-1",
cert: Data = Data([0x30, 0x01, 0x02]),
caChain: [Data] = [Data([0x30, 0xAA])],
notBefore: String = "2026-07-08T00:00:00.000Z",
notAfter: String = "2026-10-06T00:00:00.000Z",
renewAfter: String = "2026-09-05T00:00:00.000Z"
) -> Data {
let json: [String: Any] = [
"deviceId": deviceId,
"cert": cert.base64EncodedString(),
"caChain": caChain.map { $0.base64EncodedString() },
"notBefore": notBefore,
"notAfter": notAfter,
"renewAfter": renewAfter,
]
return try! JSONSerialization.data(withJSONObject: json)
}
@Test("enroll builds a bearer-authenticated POST /device/enroll with the A4 body")
func enrollBuildsRequest() async throws {
// Arrange
let stub = StubTransport(status: 201, body: enrollBody())
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
let csr = Data([0xDE, 0xAD, 0xBE, 0xEF])
// Act
_ = try await client.enroll(csrDER: csr, subdomain: "alice", deviceName: "Alice iPhone")
// Assert
let request = try #require(stub.lastRequest)
#expect(request.httpMethod == "POST")
#expect(request.url?.absoluteString == "https://cp.terminal.yaojia.wang/device/enroll")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer \(bearer)")
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
let sent = try #require(request.httpBody)
let object = try #require(
try JSONSerialization.jsonObject(with: sent) as? [String: Any]
)
#expect(object["csr"] as? String == csr.base64EncodedString())
#expect(object["keyAlg"] as? String == "ec-p256")
#expect(object["subdomain"] as? String == "alice")
#expect(object["deviceName"] as? String == "Alice iPhone")
#expect(object["attestation"] == nil) // omitted when not provided
}
@Test("enroll maps a 201 response into a typed EnrollmentResult")
func enrollMapsResponse() async throws {
// Arrange
let cert = Data([0x30, 0x82, 0x01, 0x23])
let ca = Data([0x30, 0x82, 0x02, 0x00])
let stub = StubTransport(status: 201, body: enrollBody(deviceId: "dev-xyz", cert: cert, caChain: [ca]))
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
// Act
let result = try await client.enroll(csrDER: Data([0x01]), subdomain: "alice", deviceName: "iPhone")
// Assert
#expect(result.deviceId == "dev-xyz")
#expect(result.certificate == cert)
#expect(result.caChain == [ca])
#expect(result.notAfter != nil)
#expect(result.renewAfter != nil)
// renewAfter (2026-09-05) is before notAfter (2026-10-06).
#expect(result.renewAfter! < result.notAfter!)
}
@Test("isRenewalDue flips at renewAfter")
func renewalDueSeam() async throws {
let stub = StubTransport(status: 201, body: enrollBody())
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
let result = try await client.enroll(csrDER: Data([0x01]), subdomain: "a", deviceName: "d")
let before = ISO8601DateFormatter().date(from: "2026-09-04T00:00:00Z")!
let after = ISO8601DateFormatter().date(from: "2026-09-06T00:00:00Z")!
#expect(result.isRenewalDue(asOf: before) == false)
#expect(result.isRenewalDue(asOf: after) == true)
}
@Test("attestation passphrase is forwarded when provided")
func enrollForwardsAttestation() async throws {
let stub = StubTransport(status: 201, body: enrollBody())
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
_ = try await client.enroll(
csrDER: Data([0x01]), subdomain: "a", deviceName: "d", attestation: "attest-blob"
)
let object = try JSONSerialization.jsonObject(
with: stub.lastRequest!.httpBody!
) as! [String: Any]
#expect(object["attestation"] as? String == "attest-blob")
}
@Test("a non-201 response throws http with the server error code")
func enrollRejectSurfacesStatus() async throws {
// 403 subdomain-not-owned { error: "rejected" }.
let body = try! JSONSerialization.data(withJSONObject: ["error": "rejected"])
let stub = StubTransport(status: 403, body: body)
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
await #expect(throws: DeviceEnrollmentError.http(status: 403, code: "rejected")) {
_ = try await client.enroll(csrDER: Data([0x01]), subdomain: "bob", deviceName: "d")
}
}
@Test("a malformed 201 body throws malformedResponse")
func enrollMalformedBody() async throws {
let stub = StubTransport(status: 201, body: Data("not json".utf8))
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
await #expect(throws: DeviceEnrollmentError.malformedResponse) {
_ = try await client.enroll(csrDER: Data([0x01]), subdomain: "a", deviceName: "d")
}
}
@Test("attestChallenge maps a 200 challenge response")
func attestChallengeMaps() async throws {
let body = try! JSONSerialization.data(
withJSONObject: ["challenge": "Y2hhbGxlbmdl", "expires_in": 120]
)
let stub = StubTransport(status: 200, body: body)
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
let challenge = try await client.attestChallenge()
#expect(challenge.challenge == "Y2hhbGxlbmdl")
#expect(challenge.expiresIn == 120)
#expect(stub.lastRequest?.url?.absoluteString
== "https://cp.terminal.yaojia.wang/device/attest/challenge")
}
@Test("renew targets POST /device/:id/renew (A6 seam)")
func renewTargetsRenewEndpoint() async throws {
let stub = StubTransport(status: 201, body: enrollBody())
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
_ = try await client.renew(deviceId: "dev-9", csrDER: Data([0x02]))
#expect(stub.lastRequest?.url?.absoluteString
== "https://cp.terminal.yaojia.wang/device/dev-9/renew")
}

View File

@@ -1,10 +1,14 @@
/**
* T9 · SPIFFE-ID scheme for per-host agent identity (INV4/INV14).
* Form: `spiffe://relay.<domain>/account/<accountId>/host/<hostId>`.
* T9 · SPIFFE-ID scheme for per-host / per-device agent identity (INV4/INV14).
* Forms: `spiffe://relay.<domain>/account/<accountId>/host/<hostId>` (frp control channel) and
* `.../account/<accountId>/device/<deviceId>` (device data path, FIX H-cp-2 / H-native-6).
*/
import type { SpiffeId } from '../types.js'
import { SpiffeIdSchema } from '../types.js'
/** Which identity arm a SPIFFE-ID names: a host agent or an end-user device. */
export type SpiffeKind = 'host' | 'device'
export class SpiffeError extends Error {
constructor(message: string) {
super(message)
@@ -18,21 +22,30 @@ export function trustDomain(env: NodeJS.ProcessEnv = process.env): string {
return d !== undefined && d.length > 0 ? d : 'example.com'
}
export function spiffeIdFor(accountId: string, hostId: string, domain = trustDomain()): SpiffeId {
if (accountId.length === 0 || hostId.length === 0) {
throw new SpiffeError('accountId and hostId are required')
/**
* Build a SPIFFE-ID. The optional 4th `kind` param defaults to `'host'`, so existing 3-arg
* callers (e.g. control-plane `ca/issue.ts`) are UNCHANGED. Pass `'device'` for device leaves.
*/
export function spiffeIdFor(
accountId: string,
id: string,
domain = trustDomain(),
kind: SpiffeKind = 'host',
): SpiffeId {
if (accountId.length === 0 || id.length === 0) {
throw new SpiffeError('accountId and id are required')
}
const id = `spiffe://relay.${domain}/account/${accountId}/host/${hostId}`
return SpiffeIdSchema.parse(id)
const uri = `spiffe://relay.${domain}/account/${accountId}/${kind}/${id}`
return SpiffeIdSchema.parse(uri)
}
const PARSE_RE =
/^spiffe:\/\/relay\.[a-z0-9.-]+\/account\/([A-Za-z0-9_-]+)\/host\/([A-Za-z0-9_-]+)$/
/^spiffe:\/\/relay\.[a-z0-9.-]+\/account\/([A-Za-z0-9_-]+)\/(host|device)\/([A-Za-z0-9_-]+)$/
export function parseSpiffeId(uri: SpiffeId): { accountId: string; hostId: string } {
export function parseSpiffeId(uri: SpiffeId): { accountId: string; id: string; kind: SpiffeKind } {
const parsed = SpiffeIdSchema.safeParse(uri) // rejects path-traversal / wildcard / malformed
if (!parsed.success) throw new SpiffeError('invalid SPIFFE-ID (malformed / traversal / wildcard)')
const m = PARSE_RE.exec(uri)
if (m === null) throw new SpiffeError('unparseable SPIFFE-ID')
return { accountId: m[1]!, hostId: m[2]! }
return { accountId: m[1]!, kind: m[2]! as SpiffeKind, id: m[3]! }
}

View File

@@ -102,17 +102,20 @@ export async function verifyAgentCert(
if (now > cert.notAfter) return { ok: false, reason: 'expired' }
if (cert.spiffeUri === null) return { ok: false, reason: 'no_spiffe_san' }
let ids: { accountId: string; hostId: string }
let ids: ReturnType<typeof parseSpiffeId>
try {
ids = parseSpiffeId(cert.spiffeUri)
} catch {
return { ok: false, reason: 'bad_spiffe_id' }
}
// Host channel is host-only. A /device/ cert must never be accepted here (its enforcement
// gate is nginx :8470), so an id that collides with an enrolled hostId can't cross tenants.
if (ids.kind !== 'host') return { ok: false, reason: 'not_host_cert' }
const host = await hosts.getById(ids.hostId)
const host = await hosts.getById(ids.id)
if (host === null) return { ok: false, reason: 'host_not_enrolled' } // INV4: not in registry
if (host.accountId !== ids.accountId) return { ok: false, reason: 'spiffe_account_mismatch' }
if (host.status === 'revoked') return { ok: false, reason: 'host_revoked' }
return { ok: true, hostId: ids.hostId, accountId: ids.accountId }
return { ok: true, hostId: ids.id, accountId: ids.accountId }
}

View File

@@ -76,6 +76,7 @@ export {
// T9 mTLS / SPIFFE
export { spiffeIdFor, parseSpiffeId, trustDomain, SpiffeError } from './agent/spiffe.js'
export type { SpiffeKind } from './agent/spiffe.js'
export {
verifyAgentCert,
defaultParseX509,

View File

@@ -97,7 +97,7 @@ export interface AuditEvent {
readonly remoteAddrHash: string
}
export type SpiffeId = string // 'spiffe://relay.<domain>/account/<accountId>/host/<hostId>'
export type SpiffeId = string // 'spiffe://relay.<domain>/account/<accountId>/(host|device)/<id>'
export interface RateLimitPolicy {
readonly connectPerMin: number
@@ -227,11 +227,13 @@ export const RateLimitPolicySchema = z
.strict()
/**
* SPIFFE-ID validator: accepts the `spiffe://relay.<domain>/account/<a>/host/<h>` form and
* REJECTS path-traversal (`..`) or wildcard (`*`). Segment chars are restricted (no `/`).
* SPIFFE-ID validator: accepts BOTH the host arm `spiffe://relay.<domain>/account/<a>/host/<h>`
* and the device arm `.../account/<a>/device/<d>` (FIX H-cp-2 / H-native-6, added in lockstep
* with the control-plane device issuer). REJECTS path-traversal (`..`) or wildcard (`*`); the
* kind segment is exactly `host` or `device`. Segment chars are restricted (no `/`).
*/
const SPIFFE_ID_RE =
/^spiffe:\/\/relay\.[a-z0-9.-]+\/account\/[A-Za-z0-9_-]+\/host\/[A-Za-z0-9_-]+$/
/^spiffe:\/\/relay\.[a-z0-9.-]+\/account\/[A-Za-z0-9_-]+\/(?:host|device)\/[A-Za-z0-9_-]+$/
export const SpiffeIdSchema = z
.string()
.regex(SPIFFE_ID_RE, 'invalid SPIFFE-ID form')

View File

@@ -59,6 +59,25 @@ describe('capability token (§4.3)', () => {
expect(tok.sub).toBe('acct-A')
})
it('issues and verifies the new enroll right — issue.ts consumes it via CapabilityRightSchema (FIX C-native-1)', async () => {
const cnfJkt = await jwkThumbprint((await makeEphemeral()).publicRaw)
const raw = await issueCapabilityToken(
{
principal: principal('acct-A'),
aud: AUD,
host: uuid(),
rights: ['enroll'],
ttlSeconds: 45,
cnfJkt,
},
signingKey,
NOW,
)
const tok = await verifyCapabilityToken(raw, AUD, NOW + 1)
expect(tok.rights).toEqual(['enroll'])
expect(hasRight(tok, 'enroll')).toBe(true)
})
it('rejects an expired token', async () => {
const raw = await issueFor(signingKey, { ttl: 30 })
await expect(verifyCapabilityToken(raw, AUD, NOW + 31)).rejects.toMatchObject({ reason: 'expired' })

View File

@@ -26,7 +26,7 @@ describe('SPIFFE-ID scheme (T9)', () => {
it('formats and round-trips account/host', () => {
const id = spiffeIdFor('acct-A', 'host-1', DOMAIN)
expect(id).toBe('spiffe://relay.example.com/account/acct-A/host/host-1')
expect(parseSpiffeId(id)).toEqual({ accountId: 'acct-A', hostId: 'host-1' })
expect(parseSpiffeId(id)).toEqual({ accountId: 'acct-A', id: 'host-1', kind: 'host' })
})
it('rejects a path-traversal / wildcard SPIFFE-ID', () => {
@@ -73,6 +73,15 @@ describe('agent cert verification (INV4/INV14)', () => {
const r = await verifyAgentCert('leaf', 'ca', NOW, hosts, certParser(cert))
expect(r).toMatchObject({ ok: false, reason: 'chain_invalid' })
})
it('refuses a device-kind cert on the host channel even if the id collides with an enrolled host (kind guard)', async () => {
// Trust-broadening guard: a /device/ SAN whose id happens to equal an enrolled hostId
// (same account) must NOT be accepted by the host-agent verifier. The host channel is
// host-only; device certs are enforced at nginx :8470, not here.
const cert = { ...validCert, spiffeUri: spiffeIdFor('acct-A', 'host-1', DOMAIN, 'device') }
const r = await verifyAgentCert('leaf', 'ca', NOW, hosts, certParser(cert))
expect(r).toMatchObject({ ok: false, reason: 'not_host_cert' })
})
})
describe('defaultParseX509 real X.509 path validation (INV4/INV14)', () => {

View File

@@ -0,0 +1,81 @@
/**
* A2a · SPIFFE-ID device-arm contract edit (FIX H-cp-2 / H-native-6).
* The host arm MUST keep working; a new /device/ arm is added in lockstep with the
* control-plane device issuer. The cross-track test pins issuer↔verifier so the two
* cannot drift (a device SAN the CP stamps must parse under relay-auth's parser).
*/
import { describe, it, expect } from 'vitest'
import { spiffeIdFor, parseSpiffeId, SpiffeError } from '../src/agent/spiffe.js'
import { SpiffeIdSchema } from '../src/types.js'
const DOMAIN = 'example.com'
describe('spiffeIdFor / parseSpiffeId — host arm (backward compatible)', () => {
it('default kind produces the exact existing host URI and round-trips', () => {
const id = spiffeIdFor('acct-A', 'host-1', DOMAIN)
expect(id).toBe('spiffe://relay.example.com/account/acct-A/host/host-1')
expect(parseSpiffeId(id)).toEqual({ accountId: 'acct-A', id: 'host-1', kind: 'host' })
})
it('3-arg call (accountId, id, domain) still works and defaults kind=host', () => {
const id = spiffeIdFor('acct-A', 'host-9', DOMAIN)
expect(parseSpiffeId(id).kind).toBe('host')
})
it('explicit kind=host matches the 3-arg default form', () => {
expect(spiffeIdFor('acct-A', 'host-1', DOMAIN, 'host')).toBe(
spiffeIdFor('acct-A', 'host-1', DOMAIN),
)
})
})
describe('spiffeIdFor / parseSpiffeId — device arm (new)', () => {
it('device kind produces a /device/ URI that passes the schema and round-trips', () => {
const id = spiffeIdFor('acct-A', 'dev-1', DOMAIN, 'device')
expect(id).toBe('spiffe://relay.example.com/account/acct-A/device/dev-1')
expect(SpiffeIdSchema.safeParse(id).success).toBe(true)
expect(parseSpiffeId(id)).toEqual({ accountId: 'acct-A', id: 'dev-1', kind: 'device' })
})
it('CROSS-TRACK: a raw device SAN as the CP issuer stamps it parses under relay-auth', () => {
// This is the exact string form control-plane/src/ca/device-issue.ts will place in the
// URI SAN. Assert relay-auth's schema + parser accept it and that it equals the shared
// builder's output — proving issuer and verifier cannot drift.
const account = 'acct-XYZ'
const deviceId = 'did-abc_123'
const stampedSan = `spiffe://relay.${DOMAIN}/account/${account}/device/${deviceId}`
expect(SpiffeIdSchema.safeParse(stampedSan).success).toBe(true)
expect(parseSpiffeId(stampedSan)).toEqual({
accountId: account,
id: deviceId,
kind: 'device',
})
expect(spiffeIdFor(account, deviceId, DOMAIN, 'device')).toBe(stampedSan)
})
})
describe('spiffeIdFor / parseSpiffeId — malformed / traversal / wildcard rejected', () => {
it('rejects path-traversal in either arm', () => {
expect(() => parseSpiffeId('spiffe://relay.example.com/account/../host/x')).toThrow(SpiffeError)
expect(() => parseSpiffeId('spiffe://relay.example.com/account/../device/x')).toThrow(
SpiffeError,
)
})
it('rejects wildcard in either arm', () => {
expect(() => parseSpiffeId('spiffe://relay.example.com/account/*/host/x')).toThrow(SpiffeError)
expect(() => parseSpiffeId('spiffe://relay.example.com/account/a/device/*')).toThrow(SpiffeError)
})
it('rejects an unknown kind segment (only host|device allowed)', () => {
expect(SpiffeIdSchema.safeParse('spiffe://relay.example.com/account/a/user/x').success).toBe(
false,
)
expect(() => parseSpiffeId('spiffe://relay.example.com/account/a/user/x')).toThrow(SpiffeError)
})
it('rejects empty accountId / id', () => {
expect(() => spiffeIdFor('', 'dev-1', DOMAIN, 'device')).toThrow(SpiffeError)
expect(() => spiffeIdFor('acct-A', '', DOMAIN, 'device')).toThrow(SpiffeError)
})
})

View File

@@ -9,9 +9,13 @@
import { z } from 'zod'
import { NotImplementedInContractsError } from '../errors.js'
/** Rights a token may carry (string-literal union, §4.3). */
export type CapabilityRight = 'attach' | 'manage' | 'kill'
export const CapabilityRightSchema = z.enum(['attach', 'manage', 'kill'])
/**
* Rights a token may carry (string-literal union, §4.3). `enroll` is the device/host
* enrollment scope minted by the login→session subsystem for a `device:enroll` token,
* kept separate from the 3060s connect clamp (FIX C-native-1). Additive — never removed.
*/
export type CapabilityRight = 'attach' | 'manage' | 'kill' | 'enroll'
export const CapabilityRightSchema = z.enum(['attach', 'manage', 'kill', 'enroll'])
export interface CapabilityToken {
readonly sub: string // principal id — from authenticated session, NOT client

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
CapabilityRightSchema,
CapabilityTokenSchema,
EnrollResultSchema,
NotImplementedInContractsError,
@@ -50,6 +51,38 @@ describe('§4.3 CapabilityToken shape validation', () => {
})
})
describe('CapabilityRight enum — enroll right (FIX C-native-1)', () => {
const validBody = {
sub: 'device:1',
aud: 'alice',
host: UUID,
rights: ['attach'],
iat: 1000,
exp: 2000,
jti: 'jti-1',
}
it('accepts the new enroll right', () => {
expect(CapabilityRightSchema.parse('enroll')).toBe('enroll')
})
it('still accepts the existing rights (backward compatible)', () => {
expect(CapabilityRightSchema.parse('attach')).toBe('attach')
expect(CapabilityRightSchema.parse('manage')).toBe('manage')
expect(CapabilityRightSchema.parse('kill')).toBe('kill')
})
it('still rejects an unknown right', () => {
expect(() => CapabilityRightSchema.parse('destroy')).toThrow()
})
it('accepts a token body carrying the enroll right', () => {
expect(CapabilityTokenSchema.parse({ ...validBody, rights: ['enroll'] }).rights).toEqual([
'enroll',
])
})
})
describe('§4.5 pairing / enroll shapes', () => {
it('accepts a valid EnrollResult', () => {
const rec = {