From e7f3bd05f06a9f9bfa014873496b641b2a7da1d3 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Fri, 10 Jul 2026 16:11:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(tunnel):=20zero-touch=20tunnel=20enrollmen?= =?UTF-8?q?t=20=E2=80=94=20control-plane=20PKI,=20host=20agent,=20iOS,=20n?= =?UTF-8?q?ginx=20isolation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- agent/src/cli.ts | 81 ++- agent/src/cli/deps.ts | 162 +++++- agent/src/config/agentConfig.ts | 13 +- agent/src/enroll/csr.ts | 34 +- agent/src/health/probe.ts | 182 ++++++ agent/src/keys/identity.ts | 72 ++- agent/src/keys/keystore.ts | 31 +- agent/src/net/loopbackLiteral.ts | 29 + agent/src/provision/frpcBinary.ts | 259 +++++++++ agent/src/provision/untar.ts | 159 ++++++ agent/src/service/install.ts | 178 ++++-- agent/src/service/launchd.ts | 55 +- agent/src/service/systemd.ts | 87 ++- agent/src/transport/frpSupervise.ts | 200 +++++++ agent/src/transport/frpcToml.ts | 155 ++++++ agent/test/agentConfig.test.ts | 10 + agent/test/cli.test.ts | 162 +++++- agent/test/csr.test.ts | 95 +++- agent/test/deps.test.ts | 103 ++++ agent/test/frpSupervise.test.ts | 148 +++++ agent/test/frpcBinary.test.ts | 416 ++++++++++++++ agent/test/frpcToml.test.ts | 100 ++++ agent/test/identity.test.ts | 52 ++ agent/test/install.test.ts | 425 +++++++++----- agent/test/keystore.test.ts | 49 +- agent/test/loopbackLiteral.test.ts | 39 ++ agent/test/probe.test.ts | 216 ++++++++ control-plane/package-lock.json | 3 + control-plane/package.json | 3 + control-plane/src/api/device-enroll.ts | 188 +++++++ control-plane/src/api/provision.ts | 41 +- control-plane/src/api/renew.ts | 358 ++++++++++++ control-plane/src/auth/session.ts | 166 ++++++ control-plane/src/boot/ca-wiring.ts | 27 +- control-plane/src/boot/native-ca.ts | 155 ++++++ control-plane/src/ca/csr-ec.ts | 120 ++++ control-plane/src/ca/device-issue.ts | 137 +++++ control-plane/src/ca/frpclient-issue.ts | 117 ++++ control-plane/src/ca/rotate.ts | 122 ++-- control-plane/src/ca/x509-assembler.ts | 177 ++++++ control-plane/src/main.ts | 115 +++- control-plane/src/pairing/native-redeem.ts | 110 ++++ control-plane/src/pairing/redeem.ts | 78 ++- control-plane/src/registry/devices.ts | 263 +++++++++ control-plane/src/store/ports.ts | 32 ++ control-plane/test/csr-ec.test.ts | 97 ++++ control-plane/test/device-enroll.test.ts | 260 +++++++++ control-plane/test/device-issue.test.ts | 133 +++++ control-plane/test/frpclient-issue.test.ts | 255 +++++++++ control-plane/test/getcertsub.test.ts | 238 ++++++++ control-plane/test/integration-native.test.ts | 372 +++++++++++++ control-plane/test/registry/devices.test.ts | 95 ++++ control-plane/test/renew.test.ts | 523 ++++++++++++++++++ control-plane/test/rotate.test.ts | 256 ++++++++- control-plane/test/session.test.ts | 117 ++++ control-plane/test/x509-assembler.test.ts | 302 ++++++++++ deploy/nginx/frp-mtls.conf | 31 ++ deploy/nginx/njs/getCertSub.d.ts | 22 + deploy/nginx/njs/getCertSub.js | 241 ++++++++ deploy/nginx/test/integration/Dockerfile | 7 + deploy/nginx/test/integration/Makefile | 15 + deploy/nginx/test/integration/nginx.test.conf | 39 ++ deploy/nginx/test/integration/run.sh | 139 +++++ docs/PROGRESS_LOG.md | 79 +++ .../ClientTLS/CertificateSigningRequest.swift | 181 ++++++ .../ClientTLS/DeviceEnrollmentClient.swift | 210 +++++++ .../KeychainClientIdentityStore.swift | 203 +++++++ .../Sources/ClientTLS/SecureEnclaveKey.swift | 200 +++++++ .../CertificateSigningRequestTests.swift | 188 +++++++ .../DeviceEnrollmentClientTests.swift | 171 ++++++ relay-auth/src/agent/spiffe.ts | 33 +- relay-auth/src/agent/verify-mtls.ts | 9 +- relay-auth/src/index.ts | 1 + relay-auth/src/types.ts | 10 +- relay-auth/test/capability.test.ts | 19 + relay-auth/test/mtls.test.ts | 11 +- relay-auth/test/spiffe.test.ts | 81 +++ relay-contracts/src/capability/token.ts | 10 +- .../test/capability-pairing.test.ts | 33 ++ 79 files changed, 9920 insertions(+), 385 deletions(-) create mode 100644 agent/src/health/probe.ts create mode 100644 agent/src/net/loopbackLiteral.ts create mode 100644 agent/src/provision/frpcBinary.ts create mode 100644 agent/src/provision/untar.ts create mode 100644 agent/src/transport/frpSupervise.ts create mode 100644 agent/src/transport/frpcToml.ts create mode 100644 agent/test/deps.test.ts create mode 100644 agent/test/frpSupervise.test.ts create mode 100644 agent/test/frpcBinary.test.ts create mode 100644 agent/test/frpcToml.test.ts create mode 100644 agent/test/loopbackLiteral.test.ts create mode 100644 agent/test/probe.test.ts create mode 100644 control-plane/src/api/device-enroll.ts create mode 100644 control-plane/src/api/renew.ts create mode 100644 control-plane/src/auth/session.ts create mode 100644 control-plane/src/boot/native-ca.ts create mode 100644 control-plane/src/ca/csr-ec.ts create mode 100644 control-plane/src/ca/device-issue.ts create mode 100644 control-plane/src/ca/frpclient-issue.ts create mode 100644 control-plane/src/ca/x509-assembler.ts create mode 100644 control-plane/src/pairing/native-redeem.ts create mode 100644 control-plane/src/registry/devices.ts create mode 100644 control-plane/test/csr-ec.test.ts create mode 100644 control-plane/test/device-enroll.test.ts create mode 100644 control-plane/test/device-issue.test.ts create mode 100644 control-plane/test/frpclient-issue.test.ts create mode 100644 control-plane/test/getcertsub.test.ts create mode 100644 control-plane/test/integration-native.test.ts create mode 100644 control-plane/test/registry/devices.test.ts create mode 100644 control-plane/test/renew.test.ts create mode 100644 control-plane/test/session.test.ts create mode 100644 control-plane/test/x509-assembler.test.ts create mode 100644 deploy/nginx/njs/getCertSub.d.ts create mode 100644 deploy/nginx/njs/getCertSub.js create mode 100644 deploy/nginx/test/integration/Dockerfile create mode 100644 deploy/nginx/test/integration/Makefile create mode 100644 deploy/nginx/test/integration/nginx.test.conf create mode 100755 deploy/nginx/test/integration/run.sh create mode 100644 ios/Packages/ClientTLS/Sources/ClientTLS/CertificateSigningRequest.swift create mode 100644 ios/Packages/ClientTLS/Sources/ClientTLS/DeviceEnrollmentClient.swift create mode 100644 ios/Packages/ClientTLS/Sources/ClientTLS/SecureEnclaveKey.swift create mode 100644 ios/Packages/ClientTLS/Tests/ClientTLSTests/CertificateSigningRequestTests.swift create mode 100644 ios/Packages/ClientTLS/Tests/ClientTLSTests/DeviceEnrollmentClientTests.swift create mode 100644 relay-auth/test/spiffe.test.ts diff --git a/agent/src/cli.ts b/agent/src/cli.ts index dac8251..fd6115b 100644 --- a/agent/src/cli.ts +++ b/agent/src/cli.ts @@ -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 --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://.terminal.`. + * * `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 + /** 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 + /** Download + verify + place the pinned frpc binary (B3); returns its path. */ + provisionFrpc(cfg: AgentConfig): Promise + /** 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 + /** Native run-loop: supervise the pinned frpc child (restart-on-exit backoff + health probe). */ + superviseFrpc(cfg: AgentConfig, ks: Keystore): Promise /** Resolve per-host install inputs (S0 env incl. loopback BIND_HOST, tunnel origin) from env/flags. */ resolveInstallOptions(): InstallOptions installService(cfg: AgentConfig, options: InstallOptions): Promise @@ -63,23 +97,60 @@ export function parseArgs(argv: readonly string[]): CliArgs { return args } +/** + * Native zero-touch onboard for `pair --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 { + const options = deps.resolveInstallOptions() + if (!options.domain) { + throw new CliUsageError( + 'native install requires TUNNEL_DOMAIN — the origin is https://.terminal.', + ) + } + 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 { 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 ` 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': { diff --git a/agent/src/cli/deps.ts b/agent/src/cli/deps.ts index 7d5d7dd..6696e74 100644 --- a/agent/src/cli/deps.ts +++ b/agent/src/cli/deps.ts @@ -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 { + 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 { + 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 `/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 => { diff --git a/agent/src/config/agentConfig.ts b/agent/src/config/agentConfig.ts index 874ca39..05675de 100644 --- a/agent/src/config/agentConfig.ts +++ b/agent/src/config/agentConfig.ts @@ -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 { diff --git a/agent/src/enroll/csr.ts b/agent/src/enroll/csr.ts index c8dcf6a..f4184c8 100644 --- a/agent/src/enroll/csr.ts +++ b/agent/src/enroll/csr.ts @@ -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= 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])) diff --git a/agent/src/health/probe.ts b/agent/src/health/probe.ts new file mode 100644 index 0000000..a5f20ab --- /dev/null +++ b/agent/src/health/probe.ts @@ -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 + /** 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 + +/** + * 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 { + 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 { + 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), +} + +/** 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, + 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) + }, + } +} diff --git a/agent/src/keys/identity.ts b/agent/src/keys/identity.ts index f0d5eb8..4576dce 100644 --- a/agent/src/keys/identity.ts +++ b/agent/src/keys/identity.ts @@ -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, diff --git a/agent/src/keys/keystore.ts b/agent/src/keys/keystore.ts index 0c76d92..4b28c00 100644 --- a/agent/src/keys/keystore.ts +++ b/agent/src/keys/keystore.ts @@ -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}`) } diff --git a/agent/src/net/loopbackLiteral.ts b/agent/src/net/loopbackLiteral.ts new file mode 100644 index 0000000..8cdd3dc --- /dev/null +++ b/agent/src/net/loopbackLiteral.ts @@ -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 = 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' +} diff --git a/agent/src/provision/frpcBinary.ts b/agent/src/provision/frpcBinary.ts new file mode 100644 index 0000000..ce39df0 --- /dev/null +++ b/agent/src/provision/frpcBinary.ts @@ -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___/{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> = { + '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> = { + arm64: 'arm64', + x64: 'amd64', +} +const SUPPORTED_OS: ReadonlySet = 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 + +/** Injectable filesystem seam (all async, mirrors `node:fs/promises`). */ +export interface FrpcFsDeps { + mkdir(dir: string): Promise + writeFile(path: string, data: Uint8Array): Promise + rename(from: string, to: string): Promise + chmod(path: string, mode: number): Promise + rm(path: string): Promise +} + +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> +} + +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 { + 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 { + 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 } +} diff --git a/agent/src/provision/untar.ts b/agent/src/provision/untar.ts new file mode 100644 index 0000000..7217e62 --- /dev/null +++ b/agent/src/provision/untar.ts @@ -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___/{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})`) + } +} diff --git a/agent/src/service/install.ts b/agent/src/service/install.ts index e604563..f859c9c 100644 --- a/agent/src/service/install.ts +++ b/agent/src/service/install.ts @@ -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://.terminal.`; 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://.terminal. [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://..` 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://..` 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 (` 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 { 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 { cmd, args } = launchdLoadCommand(path) - await deps.runCommand(cmd, args) + 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() - await deps.runCommand(cmd, args) + + 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, ): Promise { if (platform === 'launchd') { - const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir())) - await deps.runCommand(cmd, args) + for (const label of [agentLabel(), baseAppLabel()]) { + const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir(), label)) + await deps.runCommand(cmd, args) + } return } - const { cmd, args } = systemdDisableCommand() - await deps.runCommand(cmd, args) + for (const unit of [agentUnitName(), baseAppUnitName()]) { + const { cmd, args } = systemdDisableCommand(unit) + await deps.runCommand(cmd, args) + } } diff --git a/agent/src/service/launchd.ts b/agent/src/service/launchd.ts index 8550082..b4d1e76 100644 --- a/agent/src/service/launchd.ts +++ b/agent/src/service/launchd.ts @@ -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 * `EnvironmentVariables` 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 (` 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> -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 `ProgramArguments` lines. */ +function programArgumentsBlock(programArguments: readonly string[]): readonly string[] { + const lines = [' ProgramArguments', ' '] + for (const arg of programArguments) { + lines.push(` ${escapeXml(arg)}`) + } + lines.push(' ') + return lines +} + /** - * Build the plist. ExecStart = ` 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 [ '', '', '', '', ' Label', - ` ${escapeXml(LABEL)}`, - ' ProgramArguments', - ' ', - ` ${escapeXml(binPath)}`, - ' run', - ' ', + ` ${escapeXml(label)}`, + ...programArgumentsBlock(programArguments), ' RunAtLoad', ' ', ' KeepAlive', diff --git a/agent/src/service/systemd.ts b/agent/src/service/systemd.ts index 5d90ff0..96166da 100644 --- a/agent/src/service/systemd.ts +++ b/agent/src/service/systemd.ts @@ -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 (` 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 = ` run`; Restart=on-failure; 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. ` run` or + * `node dist/server.js`); Restart=on-failure; 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] } } diff --git a/agent/src/transport/frpSupervise.ts b/agent/src/transport/frpSupervise.ts new file mode 100644 index 0000000..6ab4667 --- /dev/null +++ b/agent/src/transport/frpSupervise.ts @@ -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 `. */ +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 { + 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 + /** Resolves with an exit code (always 0 — a stopped supervisor is a clean shutdown). */ + readonly done: Promise + /** 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((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 { + return new Promise((resolve) => { + const c = spawn(binPath, tomlPath) + child = c + c.onExit((code) => { + child = null + resolve(code) + }) + }) + } + + async function loop(): Promise { + 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 { + stopped = true + child?.kill() + await done + }, + done, + isChildAlive: () => child?.isAlive() ?? false, + } +} diff --git a/agent/src/transport/frpcToml.ts b/agent/src/transport/frpcToml.ts new file mode 100644 index 0000000..9a26235 --- /dev/null +++ b/agent/src/transport/frpcToml.ts @@ -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 `.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://.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') +} diff --git a/agent/test/agentConfig.test.ts b/agent/test/agentConfig.test.ts index d990fc0..cec088f 100644 --- a/agent/test/agentConfig.test.ts +++ b/agent/test/agentConfig.test.ts @@ -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() }) diff --git a/agent/test/cli.test.ts b/agent/test/cli.test.ts index d46df29..d32ae7c 100644 --- a/agent/test/cli.test.ts +++ b/agent/test/cli.test.ts @@ -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 = {}, 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 => ({ hostId: 'h-1', subdomain: 'host-42', @@ -48,8 +57,13 @@ function deps(overrides: Partial = {}, 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) diff --git a/agent/test/csr.test.ts b/agent/test/csr.test.ts index ee7331e..a5dcaac 100644 --- a/agent/test/csr.test.ts +++ b/agent/test/csr.test.ts @@ -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) + }) +}) diff --git a/agent/test/deps.test.ts b/agent/test/deps.test.ts new file mode 100644 index 0000000..97722c5 --- /dev/null +++ b/agent/test/deps.test.ts @@ -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 `/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 { + 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 child’s 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 }) + } + }) +}) diff --git a/agent/test/frpSupervise.test.ts b/agent/test/frpSupervise.test.ts new file mode 100644 index 0000000..e19d0ce --- /dev/null +++ b/agent/test/frpSupervise.test.ts @@ -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 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) + }) +}) diff --git a/agent/test/frpcBinary.test.ts b/agent/test/frpcBinary.test.ts new file mode 100644 index 0000000..ecbaad0 --- /dev/null +++ b/agent/test/frpcBinary.test.ts @@ -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 + renames: Array<{ from: string; to: string }> + removed: string[] + chmods: Array<{ path: string; mode: number }> + mkdirs: string[] +} + +function makeFakeDeps(bytesByUrl: Record): { + 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 { + 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([]) + }) +}) diff --git a/agent/test/frpcToml.test.ts b/agent/test/frpcToml.test.ts new file mode 100644 index 0000000..44eade1 --- /dev/null +++ b/agent/test/frpcToml.test.ts @@ -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() + }) +}) diff --git a/agent/test/identity.test.ts b/agent/test/identity.test.ts index 6473506..dd54669 100644 --- a/agent/test/identity.test.ts +++ b/agent/test/identity.test.ts @@ -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') + }) }) diff --git a/agent/test/install.test.ts b/agent/test/install.test.ts index e533cba..bd8331d 100644 --- a/agent/test/install.test.ts +++ b/agent/test/install.test.ts @@ -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') - expect(unit).toContain('User=alice') - expect(unit).not.toContain('User=root') - expect(unit).toContain('Restart=on-failure') - expect(d.runs[0]![0]).toBe('systemctl') + // exactly two units + expect(d.writes).toHaveLength(2) + const baseApp = unitWith(d, baseAppUnitName()) + const agent = unitWith(d, agentUnitName()) + // agent unit supervises frpc via ` 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') + } + // 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('run') - expect(plist).toContain('KeepAlive') - expect(d.runs[0]![0]).toBe('launchctl') + expect(d.writes).toHaveLength(2) + const baseApp = unitWith(d, baseAppLabel()) + const agent = unitWith(d, agentLabel()) + expect(agent).toContain('run') + expect(baseApp).toContain('server.js') + expect(baseApp).not.toContain('run') + for (const plist of [baseApp, agent]) { + expect(plist).toContain('KeepAlive') + } + 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) + }) + + it('uninstall tears down BOTH units (systemd disable)', async () => { + const d = deps() + 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()) }) }) -const TUNNEL_ENV = { - BIND_HOST: '127.0.0.1', - ALLOWED_ORIGINS: 'https://t1.terminal.yaojia.wang', - PORT: '3000', -} as const +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') + }) -describe('env injection into the writers (PLAN_NATIVE_TUNNEL S2)', () => { - it('launchd: default (no options) omits the EnvironmentVariables block', async () => { + 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) - const [, plist] = d.writes[0]! - expect(plist).not.toContain('EnvironmentVariables') + 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: injects a sorted, XML-escaped EnvironmentVariables dict', async () => { + it('NEGATIVE (launchd): a 0.0.0.0 install emits no plist', async () => { const d = deps() - await installService(CFG, 'launchd', d, { env: TUNNEL_ENV }) - const [, plist] = d.writes[0]! - expect(plist).toContain('EnvironmentVariables') - expect(plist).toContain('BIND_HOST') - expect(plist).toContain('127.0.0.1') - // 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, 'launchd', d, { env: { BIND_HOST: '0.0.0.0' } }), + ).rejects.toBeInstanceOf(BindHostError) + expect(d.writes).toHaveLength(0) }) - it('launchd: escapes XML-significant characters in env values', () => { - const plist = buildLaunchdPlist('/bin/agent', { X: `a&bd"e'f` }) - expect(plist).toContain('a&b<c>d"e'f') - expect(plist).not.toContain('a&bd') - }) - - it('systemd: default (no options) omits Environment lines', 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) - const [, unit] = d.writes[0]! - expect(unit).not.toContain('Environment') - }) - - it('systemd: emits sorted, quoted Environment= lines from the env map', 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://.. 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('ALLOWED_ORIGINS') - expect(plist).toContain('https://host-42.terminal.yaojia.wang') - }) - - 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('https://host-42.term.yaojia.wang') - }) - - 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. - 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 () => { - const d = deps() - await installService(CFG, 'launchd', d, buildInstallOptions(ENV)) - const [, plist] = d.writes[0]! - expect(plist).toContain('EnvironmentVariables') - expect(plist).toContain('BIND_HOST') - expect(plist).toContain('127.0.0.1') - expect(plist).toContain('https://host-42.terminal.yaojia.wang') - expect(plist).toContain('PORT') - expect(plist).not.toContain('0.0.0.0') +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('systemd: the unit carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => { + it('merges https://.terminal. into the base-app 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') + await installService(CFG, 'launchd', d, { domain: 'yaojia.wang', zone: 'terminal' }) + const baseApp = unitWith(d, baseAppLabel()) + expect(baseApp).toContain('ALLOWED_ORIGINS') + expect(baseApp).toContain('https://host-42.terminal.yaojia.wang') + }) + + 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('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('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 base-app plist carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => { + const d = deps() + await installService(CFG, 'launchd', d, buildInstallOptions(ENV)) + const baseApp = unitWith(d, baseAppLabel()) + expect(baseApp).toContain('BIND_HOST') + expect(baseApp).toContain('127.0.0.1') + expect(baseApp).toContain('https://host-42.terminal.yaojia.wang') + expect(baseApp).toContain('PORT') + expect(baseApp).not.toContain('0.0.0.0') + }) + + 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 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('unit writers — escaping & control-char hardening', () => { + it('launchd: escapes XML-significant characters in env values', () => { + const plist = buildLaunchdPlist(['/bin/agent', 'run'], { X: `a&bd"e'f` }) + expect(plist).toContain('a&b<c>d"e'f') + expect(plist).not.toContain('a&bd') + }) + + 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('EnvironmentVariables') + 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') }) }) diff --git a/agent/test/keystore.test.ts b/agent/test/keystore.test.ts index ffd247e..8b17447 100644 --- a/agent/test/keystore.test.ts +++ b/agent/test/keystore.test.ts @@ -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/) + }) }) diff --git a/agent/test/loopbackLiteral.test.ts b/agent/test/loopbackLiteral.test.ts new file mode 100644 index 0000000..024fb53 --- /dev/null +++ b/agent/test/loopbackLiteral.test.ts @@ -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) + }) +}) diff --git a/agent/test/probe.test.ts b/agent/test/probe.test.ts new file mode 100644 index 0000000..26f4647 --- /dev/null +++ b/agent/test/probe.test.ts @@ -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 { + 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({ + 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() + }) +}) diff --git a/control-plane/package-lock.json b/control-plane/package-lock.json index 6afa87e..3e3bf78 100644 --- a/control-plane/package-lock.json +++ b/control-plane/package-lock.json @@ -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", diff --git a/control-plane/package.json b/control-plane/package.json index 091ffe2..f72cc9a 100644 --- a/control-plane/package.json +++ b/control-plane/package.json @@ -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", diff --git a/control-plane/src/api/device-enroll.ts b/control-plane/src/api/device-enroll.ts new file mode 100644 index 0000000..5607b6d --- /dev/null +++ b/control-plane/src/api/device-enroll.ts @@ -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.` 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 +} + +/** 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 { + 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) + } + }) + } +} diff --git a/control-plane/src/api/provision.ts b/control-plane/src/api/provision.ts index 8ed040b..5ff3210 100644 --- a/control-plane/src/api/provision.ts +++ b/control-plane/src/api/provision.ts @@ -15,7 +15,9 @@ import type { HostRegistry } from '../registry/hosts.js' import type { PairingIssuer } from '../pairing/issue.js' import type { PairingRedeemer } from '../pairing/redeem.js' import { RedeemError } from '../pairing/redeem.js' +import type { NativeHostEnroller } from '../pairing/native-redeem.js' import { decodeCsrWire } from '../ca/csr.js' +import { isEcP256Csr } from '../ca/csr-ec.js' import type { Deprovisioner } from '../deprovision/deprovision.js' export interface ProvisionDeps { @@ -25,6 +27,12 @@ export interface ProvisionDeps { readonly pairingIssuer: PairingIssuer readonly redeemer: PairingRedeemer readonly deprovisioner: Deprovisioner + /** + * Native (EC-P256) frp-client enrollment arm. When present, an EC-P256 CSR on `/enroll` is routed + * here (P-256 frp-client leaf); Ed25519 CSRs always take the relay `redeemer`. Optional/additive: + * absent → EC CSRs fall through to the relay arm, which rejects a non-Ed25519 key (deny-by-default). + */ + readonly nativeEnroller?: NativeHostEnroller } const PlanSchema = z.enum(['free', 'personal', 'pro', 'team']) @@ -33,7 +41,10 @@ const EnrollSchema = z.object({ code: z.string().min(1), agentPubkey: z.string().min(1), // base64 (agent sends base64url; Buffer decodes both) csr: z.string().min(1), // PKCS#10: PEM block (agent) or base64(DER) — see decodeCsrWire + machineId: z.string().min(1).max(256).optional(), // native arm only; accepted + audited, no dedup yet (M-cp-idempotent) }) +// NOT `.strict()`: any client-supplied `subdomain` (or other extra field) is INERT — the native arm's +// SAN comes ONLY from the server-assigned host binding (A4 anti-smuggling), never from the body. function sendError(reply: FastifyReply, err: unknown): void { if (err instanceof AuthzError) { @@ -124,11 +135,31 @@ export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync { // NOT capability-token gated — guarded by the single-use pairing code (T8). try { const body = EnrollSchema.parse(req.body) - const result = await deps.redeemer.redeemPairingCode({ - code: body.code, - agentPubkey: new Uint8Array(Buffer.from(body.agentPubkey, 'base64')), - csr: decodeCsrWire(body.csr), - }) + const agentPubkey = new Uint8Array(Buffer.from(body.agentPubkey, 'base64')) + const csr = decodeCsrWire(body.csr) + + // Fork by CSR key algorithm: an EC-P256 CSR is a NATIVE frp-client host → P-256 leaf; an + // Ed25519 CSR is the legacy relay host (unchanged). Routing is a pure SPKI parse; each arm + // verifies the CSR self-signature inside its own gate. + if (deps.nativeEnroller !== undefined && isEcP256Csr(csr)) { + const native = await deps.nativeEnroller.enrollNativeHost({ + code: body.code, + agentPubkey, + csr, + ...(body.machineId !== undefined ? { machineId: body.machineId } : {}), + }) + // Native tunnel has no E2E-relay content secret (L-host-hcs); cert/caChain are base64(DER). + await reply.code(201).send({ + hostId: native.hostId, + subdomain: native.subdomain, + cert: Buffer.from(native.cert).toString('base64'), + caChain: native.caChain.map((der) => Buffer.from(der).toString('base64')), + hostContentSecret: null, + }) + return + } + + const result = await deps.redeemer.redeemPairingCode({ code: body.code, agentPubkey, csr }) // base64URL (not base64) — the agent decodes this via decodeBase64UrlBytes (enroll/pair.ts). await reply.code(201).send({ ...result, hostContentSecret: Buffer.from(result.hostContentSecret).toString('base64url') }) } catch (err) { diff --git a/control-plane/src/api/renew.ts b/control-plane/src/api/renew.ts new file mode 100644 index 0000000..7851967 --- /dev/null +++ b/control-plane/src/api/renew.ts @@ -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/` 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() + + /** 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) + } + }) + } +} diff --git a/control-plane/src/auth/session.ts b/control-plane/src/auth/session.ts new file mode 100644 index 0000000..e194e42 --- /dev/null +++ b/control-plane/src/auth/session.ts @@ -0,0 +1,166 @@ +/** + * A4 (FIX C-native-1) — the MINIMAL device:enroll session-token subsystem + a login stub seam. + * + * `/device/enroll` is the one endpoint that cannot require a client cert (chicken-and-egg), so it is + * gated by a short-lived, narrowly-scoped `device:enroll` bearer minted after the user's one-time + * login. That bearer IS a §4.3 capability token with `rights:['enroll']` (the right added in A2a), + * `sub = accountId`, a DISTINCT audience (`device-enroll`), and a MINUTES-scale TTL that is + * deliberately SEPARATE from the 30–60s connect clamp. + * + * WHY NOT `issueCapabilityToken`: relay-auth's connect-token issuer hard-clamps TTL to ≤60s (the + * Finding-4 blast-radius clamp) and requires a single-host + DPoP `cnf.jkt`. An enroll token is not + * host-scoped and must live for minutes, so we mint via the SAME underlying primitive + * (`signPaseto`, Ed25519 v4.public — relay-auth crypto, NO new crypto) and build the identical §4.3 + * body shape so the FROZEN verifier (`verifyCapabilityToken`) accepts it unchanged. Verification + * therefore goes through the exact path the control-plane already uses (boot/verifier.ts), then + * asserts the `enroll` right — deny-by-default on anything else. + * + * LOGIN is a STUB SEAM for the single-tenant MVP (`loginToAccountId`): it resolves an authenticated + * credential to an `accountId`. A full end-user auth layer (RFC 8628 device grant / OIDC) layers on + * here later WITHOUT changing the token shape or the verify path. + */ +import type { CapabilityRight, CapabilityToken } from 'relay-contracts' +import { verifyCapabilityToken } from 'relay-auth' +import { signPaseto } from 'relay-auth/src/crypto/paseto.js' +import { randomBytes, randomUUID } from 'node:crypto' +import { timingSafeEqualBytes } from '../util/bytes.js' + +/** Distinct audience for device enrollment (Host-confusion guard — never a subdomain aud). */ +export const DEVICE_ENROLL_AUD = 'device-enroll' +/** Default enroll-token TTL: 10 minutes (minutes-scale, separate from the 30–60s connect clamp). */ +export const DEFAULT_DEVICE_ENROLL_TTL_SEC = 10 * 60 +/** Floor: a device:enroll token must outlive the connect clamp to be meaningfully separate. */ +export const MIN_DEVICE_ENROLL_TTL_SEC = 60 +/** Ceiling: still short-lived (leaked-bearer blast radius). */ +export const MAX_DEVICE_ENROLL_TTL_SEC = 60 * 60 + +/** Enroll tokens are not host-scoped; the frozen §4.3 shape requires a non-empty `host` — sentinel. */ +const ENROLL_HOST_SENTINEL = 'device-enroll' + +/** Uniform auth reject for the device-enroll surface. 401 = bad/missing token, 403 = lacks right. */ +export class DeviceEnrollAuthError extends Error { + constructor( + public readonly status: 401 | 403, + message = 'device enrollment auth rejected', + ) { + super(message) + this.name = 'DeviceEnrollAuthError' + } +} + +/** The frozen §4.3 body + the additive `cnf.jkt` claim the verifier requires (RFC 7800). */ +interface EnrollTokenBody { + readonly sub: string + readonly aud: string + readonly host: string + readonly rights: readonly CapabilityRight[] + readonly iat: number + readonly exp: number + readonly jti: string + readonly cnf: { readonly jkt: string } +} + +export interface MintDeviceEnrollOpts { + /** The session subsystem's Ed25519 signing key (WebCrypto). Never held globally (INV9). */ + readonly signingKey: CryptoKey + readonly ttlSeconds?: number + readonly now?: number // epoch seconds + readonly aud?: string + /** Optional DPoP thumbprint. Enroll-token PoP binding is deferred; a placeholder is used if absent. */ + readonly cnfJkt?: string + readonly jti?: string +} + +function clampTtl(ttl: number): number { + return Math.min(Math.max(ttl, MIN_DEVICE_ENROLL_TTL_SEC), MAX_DEVICE_ENROLL_TTL_SEC) +} + +/** A 43-char base64url placeholder satisfying the shared verifier's `cnf.jkt` (min-1) requirement. */ +function placeholderJkt(): string { + return Buffer.from(randomBytes(32)).toString('base64url') +} + +/** + * Mint a `device:enroll` capability token: `rights:['enroll']`, `sub = accountId`, `aud = device-enroll`, + * minutes-scale TTL. Signed with the caller-supplied Ed25519 key via relay-auth's PASETO primitive. + */ +export async function mintDeviceEnrollToken(accountId: string, opts: MintDeviceEnrollOpts): Promise { + 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') +} diff --git a/control-plane/src/boot/ca-wiring.ts b/control-plane/src/boot/ca-wiring.ts index 7d67fb1..520bf37 100644 --- a/control-plane/src/boot/ca-wiring.ts +++ b/control-plane/src/boot/ca-wiring.ts @@ -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' })) + }, + } +} diff --git a/control-plane/src/boot/native-ca.ts b/control-plane/src/boot/native-ca.ts new file mode 100644 index 0000000..95c76ad --- /dev/null +++ b/control-plane/src/boot/native-ca.ts @@ -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 { + 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 { + // 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 { + 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 } +} diff --git a/control-plane/src/ca/csr-ec.ts b/control-plane/src/ca/csr-ec.ts new file mode 100644 index 0000000..d82c8ac --- /dev/null +++ b/control-plane/src/ca/csr-ec.ts @@ -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 { + 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 } +} diff --git a/control-plane/src/ca/device-issue.ts b/control-plane/src/ca/device-issue.ts new file mode 100644 index 0000000..70b2185 --- /dev/null +++ b/control-plane/src/ca/device-issue.ts @@ -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 `.` (the nginx :8470 enforcement key, FIX H-2) + URI + * `spiffe://relay./account//device/` (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 +} + +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: `.` (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 `.` + 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 } + }, + } +} diff --git a/control-plane/src/ca/frpclient-issue.ts b/control-plane/src/ca/frpclient-issue.ts new file mode 100644 index 0000000..59ab8a2 --- /dev/null +++ b/control-plane/src/ca/frpclient-issue.ts @@ -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 .` is the ENFORCEMENT key the + * A3 nginx njs parses; the `URI` SPIFFE-ID (`.../host/`, 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 { + 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 `.`, 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 } + }, + } +} diff --git a/control-plane/src/ca/rotate.ts b/control-plane/src/ca/rotate.ts index 229165e..8557276 100644 --- a/control-plane/src/ca/rotate.ts +++ b/control-plane/src/ca/rotate.ts @@ -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 + /** + * 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 } -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 } }, } } diff --git a/control-plane/src/ca/x509-assembler.ts b/control-plane/src/ca/x509-assembler.ts new file mode 100644 index 0000000..f4b9752 --- /dev/null +++ b/control-plane/src/ca/x509-assembler.ts @@ -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 { + 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 { + 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)) +} diff --git a/control-plane/src/main.ts b/control-plane/src/main.ts index 2da8bd5..f079e1e 100644 --- a/control-plane/src/main.ts +++ b/control-plane/src/main.ts @@ -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 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 { const stores = overrides.stores ?? createMemoryStores() const bus: RevocationBus = overrides.bus ?? createInMemoryRevocationBus() const caChainDer = overrides.caChainDer ?? [] @@ -142,7 +177,81 @@ export async function buildControlPlane( expectedAud: env.baseDomain, }) + // ── Native-tunnel PKI: frp-client-CA (host) + device-CA (device), both P-256 (§1.3) ─────────────── + // Production is fail-closed: `buildNativeCas` refuses to boot without real KMS-backed material, and + // the renew route MUST validate presented certs against non-empty anchors (renew.ts). DEV generates + // self-signed in-process P-256 CAs so leaves chain to a real, re-parseable CA. + const production = overrides.production ?? process.env.NODE_ENV === 'production' + const nativeCas = await buildNativeCas(env, { + production, + ...(overrides.kmsResolver !== undefined ? { kmsResolver: overrides.kmsResolver } : {}), + ...(overrides.nativeCaMaterial !== undefined ? { material: overrides.nativeCaMaterial } : {}), + }) + const nativeDnsZone = overrides.nativeDnsZone ?? DEFAULT_NATIVE_DNS_ZONE + + // ONE DeviceStore shared across the device registry, the device signer, and the renew path so the + // signer's gate + renew-expiry bump all see the device the enroll route just registered. + const deviceStore = createMemoryDeviceStore() + const deviceRegistry = createDeviceRegistry({ devices: deviceStore }) + + const hostSigner = createFrpClientLeafSigner({ + hosts: stores.hosts, + signer: nativeCas.frpClientCa.signer, + issuerName: nativeCas.frpClientCa.issuerName, + caChainDer: nativeCas.frpClientCa.anchorsDer, + trustDomain: env.relayTrustDomain, + dnsZone: nativeDnsZone, + }) + // Native (EC-P256) `/enroll` arm: pairing-gated host onboarding that mints the FIRST frp-client leaf + // (mirrors the Ed25519 relay redeemer, but issues a P-256 leaf under a SERVER-assigned subdomain and + // returns no E2E-relay content secret — L-host-hcs). Shares the frp-client `hostSigner` above so the + // enroll-issued leaf and the later /renew leaf are stamped by the SAME CA + subdomain. + const nativeEnroller = createNativeHostEnroller({ + pairing: stores.pairing, + hosts, + subdomains, + hostSigner, + pairingMaxRedeemAttempts: env.pairingMaxRedeemAttempts, + audit, + }) + const deviceSigner = createDeviceLeafSigner({ + signer: nativeCas.deviceCa.signer, + issuer: nativeCas.deviceCa.issuerName, + caChainDer: nativeCas.deviceCa.anchorsDer, + sanBaseDomain: nativeDnsZone, + trustDomain: env.relayTrustDomain, + devices: deviceStore, + }) + const renewer = createLeafRenewer({ hostSigner, deviceSigner, deviceExpiry: deviceRegistry }) + + // The device leaf's dNSName SAN is nginx :8470's single tenant boundary, so the enroll route must + // NOT trust the client-supplied subdomain: resolve who OWNS it against the host-onboarding registry + // (deny-by-default: unknown → null). Same source of truth as `HostStore.getBySubdomain(...)`. + const ownership: SubdomainOwnershipResolver = { + async ownerOfSubdomain(subdomain) { + return (await hosts.getHostBySubdomain(subdomain))?.accountId ?? null + }, + } + + // Fail-closed: the renew route validates presented certs against these anchors; in production they + // MUST be non-empty (renew.ts: "production wiring MUST supply them"). Refuse to boot otherwise. + if (production && (nativeCas.frpClientCa.anchorsDer.length === 0 || nativeCas.deviceCa.anchorsDer.length === 0)) { + throw new Error('native-tunnel renew anchors must be non-empty in production (fail-closed) — refusing to boot') + } + const app = Fastify({ logger: false }) - await app.register(buildRouter({ authorizer, accounts, hosts, pairingIssuer, redeemer, deprovisioner })) - return { app, stores } + await app.register(buildRouter({ authorizer, accounts, hosts, pairingIssuer, redeemer, deprovisioner, nativeEnroller })) + // Device enrollment is bearer-gated by the SAME capability verifier seam the admin API uses. + await app.register(buildDeviceEnrollRouter({ verifier, devices: deviceRegistry, signer: deviceSigner, ownership })) + // Leaf renewal is mTLS-authenticated (current client cert) — anchors chain-validate the presented cert. + await app.register( + buildRenewRouter({ + hosts, + devices: deviceRegistry, + renewer, + hostCaAnchorsDer: nativeCas.frpClientCa.anchorsDer, + deviceCaAnchorsDer: nativeCas.deviceCa.anchorsDer, + }), + ) + return { app, stores, nativeTunnel: { nativeCas, hostSigner, deviceSigner, deviceRegistry } } } diff --git a/control-plane/src/pairing/native-redeem.ts b/control-plane/src/pairing/native-redeem.ts new file mode 100644 index 0000000..62129bd --- /dev/null +++ b/control-plane/src/pairing/native-redeem.ts @@ -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 +} + +/** 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 } + }, + } +} diff --git a/control-plane/src/pairing/redeem.ts b/control-plane/src/pairing/redeem.ts index c81b379..a92c4dd 100644 --- a/control-plane/src/pairing/redeem.ts +++ b/control-plane/src/pairing/redeem.ts @@ -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, diff --git a/control-plane/src/registry/devices.ts b/control-plane/src/registry/devices.ts new file mode 100644 index 0000000..effa7cc --- /dev/null +++ b/control-plane/src/registry/devices.ts @@ -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 +} + +export interface DeviceRegistry extends DeviceExpiryRenewer { + registerDevice(input: RegisterDeviceInput): Promise + getDevice(deviceId: string): Promise + listDevices(accountId: string): Promise + setDeviceStatus(deviceId: string, status: DeviceStatus): Promise + ownsDevice(accountId: string, deviceId: string): Promise + /** Throws `DeviceLimitError('cap_exceeded')` when the account already holds `deviceCap` ACTIVE devices. */ + assertUnderCap(accountId: string): Promise + /** 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() + + 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() + 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() + }, + } +} diff --git a/control-plane/src/store/ports.ts b/control-plane/src/store/ports.ts index e109498..cfbfc84 100644 --- a/control-plane/src/store/ports.ts +++ b/control-plane/src/store/ports.ts @@ -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 @@ -52,6 +55,35 @@ export interface SessionStore { get(sessionId: string): Promise } +/** + * 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 + get(deviceId: string): Promise + listByAccount(accountId: string): Promise + /** Count of ACTIVE (non-revoked) devices for the per-account cap. */ + countActiveByAccount(accountId: string): Promise + /** Atomic status version + pointer swap. `revokedAt` set only on 'revoked'. Returns NEW record. */ + swapStatus( + deviceId: string, + status: DeviceStatus, + revokedAt: string | null, + changedBy: string, + ): Promise + /** + * 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 + versions(deviceId: string): Promise +} + /** Reservation of a subdomain label; atomic single-winner under concurrency (INV1). */ export interface SubdomainStore { reserve(subdomain: string): Promise // false ⇒ already taken diff --git a/control-plane/test/csr-ec.test.ts b/control-plane/test/csr-ec.test.ts new file mode 100644 index 0000000..4a5a9ca --- /dev/null +++ b/control-plane/test/csr-ec.test.ts @@ -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) + }) +}) diff --git a/control-plane/test/device-enroll.test.ts b/control-plane/test/device-enroll.test.ts new file mode 100644 index 0000000..73aa7e0 --- /dev/null +++ b/control-plane/test/device-enroll.test.ts @@ -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 { + 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> = { alice: ACCOUNT_A, bob: ACCOUNT_A } +const ownership: SubdomainOwnershipResolver = { + async ownerOfSubdomain(sub) { + return SUBDOMAIN_OWNERS[sub] ?? null + }, +} + +async function csrBody(subdomain = 'alice'): Promise> { + 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) + }) +}) diff --git a/control-plane/test/device-issue.test.ts b/control-plane/test/device-issue.test.ts new file mode 100644 index 0000000..ac0e56e --- /dev/null +++ b/control-plane/test/device-issue.test.ts @@ -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 `.terminal.yaojia.wang` + * + the device SPIFFE URI (`.../device/`, 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 { + return new Uint8Array(await webcrypto.subtle.exportKey('spki', pub)) +} + +function record(overrides: Partial): 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 { + 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 .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) + }) +}) diff --git a/control-plane/test/frpclient-issue.test.ts b/control-plane/test/frpclient-issue.test.ts new file mode 100644 index 0000000..a51a652 --- /dev/null +++ b/control-plane/test/frpclient-issue.test.ts @@ -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 { + 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 { + return webcrypto.subtle.importKey('spki', new Uint8Array(spkiDer), { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify']) +} + +async function spkiOf(pub: CryptoKey): Promise { + 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) { + 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): Promise { + 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 '.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) + }) +}) diff --git a/control-plane/test/getcertsub.test.ts b/control-plane/test/getcertsub.test.ts new file mode 100644 index 0000000..31a5c22 --- /dev/null +++ b/control-plane/test/getcertsub.test.ts @@ -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 { + 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 { + 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 { "~^(?[^:]+):(?P=s)\\.terminal\\.yaojia\\.wang$" 1; default 0; } + * PCRE `(?P=s)` is the named backreference; JS spells the same backreference `\k`. 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 `/host/` form and - * REJECTS path-traversal (`..`) or wildcard (`*`). Segment chars are restricted (no `/`). + * SPIFFE-ID validator: accepts BOTH the host arm `spiffe://relay./account//host/` + * and the device arm `.../account//device/` (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') diff --git a/relay-auth/test/capability.test.ts b/relay-auth/test/capability.test.ts index d9ae232..7c6682f 100644 --- a/relay-auth/test/capability.test.ts +++ b/relay-auth/test/capability.test.ts @@ -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' }) diff --git a/relay-auth/test/mtls.test.ts b/relay-auth/test/mtls.test.ts index c2422f2..101ef85 100644 --- a/relay-auth/test/mtls.test.ts +++ b/relay-auth/test/mtls.test.ts @@ -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)', () => { diff --git a/relay-auth/test/spiffe.test.ts b/relay-auth/test/spiffe.test.ts new file mode 100644 index 0000000..dbb9ae7 --- /dev/null +++ b/relay-auth/test/spiffe.test.ts @@ -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) + }) +}) diff --git a/relay-contracts/src/capability/token.ts b/relay-contracts/src/capability/token.ts index 217e230..eefd4fb 100644 --- a/relay-contracts/src/capability/token.ts +++ b/relay-contracts/src/capability/token.ts @@ -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 30–60s 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 diff --git a/relay-contracts/test/capability-pairing.test.ts b/relay-contracts/test/capability-pairing.test.ts index a55bb70..de888e8 100644 --- a/relay-contracts/test/capability-pairing.test.ts +++ b/relay-contracts/test/capability-pairing.test.ts @@ -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 = {