Compare commits

14 Commits

Author SHA1 Message Date
Yaojia Wang
bb0949553c feat(desktop): remote-host mode + OS-store client-cert mTLS (D-1/D-2)
- remote-hosts.ts + DesktopPrefs (remoteHosts/selectedHostId, immutable, back-compat) + tray host-picker.
- will-navigate lock is now dynamic to the selected remote origin, fails closed on null (no open-redirect).
- select-client-certificate handler: event.preventDefault() first, pick by issuer CN, non-silent
  missing-cert dialog + cert-less callback -> clean nginx rejection (Chromium sources certs from OS store).
Verified: npm run typecheck exit 0; pure-helper assertions pass.
2026-07-07 09:42:12 +02:00
Yaojia Wang
e38e6d1689 feat(ios): device client-cert mTLS — ClientTLS package, transport wiring, install UX (C-iOS)
- ios/Packages/ClientTLS: SecIdentity wrapper, PKCS12 importer (typed errors), keychain store
  (AfterFirstUnlockThisDeviceOnly), pure MutualTLSChallengeResponder truth table, cross-platform
  X.509 DER summary. 14/14 tests.
- Both transports (SessionCore URLSessionTermTransport, App URLSessionHTTPTransport) + SessionThumbnail
  take a lazy @Sendable ()->ClientIdentity? provider: WS resolves per-connect, HTTP per client-cert
  challenge, so a freshly-imported cert applies without an app relaunch. AppEnvironment injects
  { store.loadedIdentityOrNil() }.
- ClientCertScreen (.fileImporter([.pkcs12]) + passphrase -> import -> keychain), reachable via a
  设备证书 entry in SessionListScreen.hostMenu. PairingViewModel gates tunnel-host probes on cert
  presence and re-maps mTLS-reject to a clientCertRejected message.
Verified: ClientTLS 14/14, SessionCore 93/93, xcodegen + xcodebuild BUILD SUCCEEDED.
2026-07-07 09:42:12 +02:00
Yaojia Wang
5337281e85 feat(deploy): device client-CA + issuance/revocation + frp/nginx mTLS templates (V2/V7)
Committable VPS artifacts for the native reverse-tunnel (no live-ops, no secrets):
- gen-device-ca.sh: EC P-256 self-signed device CA (CA:TRUE pathlen:0, keyCertSign+cRLSign),
  openssl ca db + empty CRL, idempotent, --kind frp-client scaffolds the control-channel CA.
- issue-device-cert.sh: P-256 leaf, EKU=clientAuth only, 825d, emits .p12 (-legacy) + .pem/.key/.fullchain,
  copy_extensions=none prevents CSR-injected serverAuth/CA:TRUE; CN traversal guard.
- revoke-device.sh: openssl ca revoke -> regenerate crl.pem (revocation enforced via -crl_check).
- frp/{frps.toml.example,frpc.example.toml}: control-channel mTLS+token, disableCustomTLSFirstByte=true.
- nginx/frp-mtls.conf: :8470 TLS-term, ssl_verify_client on + device-CA + ssl_crl, WS-upgrade proxy to :7080.
- nginx/stream-sni-additions.md: the two additive SNI map lines (merge-only, coexistence-safe).
Verified: gen->issue->openssl verify->revoke->CRL chain green in a tmp dir (OpenSSL 3.0.18).
2026-07-07 09:42:12 +02:00
Yaojia Wang
6b8269c1c1 docs: multi-tenant mTLS reverse-tunnel dev plan (VPS + server + native clients)
Generated via multi-agent workflow (3 planners → adversarial review → synthesis).
Covers frp tunnel on shared :443, mTLS device-cert auth, and client-cert
implementation for iOS/Android/desktop. Trust Model A (single-owner fleet) for v1.
2026-07-07 08:00:40 +02:00
Yaojia Wang
c1c837c54f fix(relay-run): buffer early ws frames in wsToWebSocketLike
The message listener was attached only when the mux consumer called onMessage — which
happens inside attach(), AFTER the async mTLS-registry lookup. On loopback the agent's FIRST
heartbeat ping arrived in that gap and ws dropped it (no listener), so the agent's heartbeat
died on the first miss at 15s and the tunnel flapped forever. Attach the listener at
construction and buffer early frames (and an early close) until the consumer wires up.
2026-07-07 04:59:53 +02:00
Yaojia Wang
89678c7949 fix(relay-run): agent-TLS ca must be full chain (intermediate+root), not intermediate-only
Node/OpenSSL rejects an agent client leaf whose only trust anchor is a non-self-signed
intermediate (no PARTIAL_CHAIN flag) → every agent was reset at the TLS layer before attach(),
surfacing as a silent 1006. Pass the full agent-ca bundle and stop swallowing tlsClientError.
2026-07-07 04:49:38 +02:00
Yaojia Wang
7af4a68ef5 fix(control-plane): encode enroll hostContentSecret as base64url (agent decodes base64url) 2026-07-06 21:50:08 +02:00
Yaojia Wang
6efed9772e feat(control-plane): real X.509 agent leaf issuance (closes enrollment↔mTLS gap)
- ca/issue.ts: real X.509 v3 Ed25519 leaf with SPIFFE URI SAN, signed by the
  intermediate; SAN built via relay-auth spiffeIdFor so it can't drift
- ca/csr.ts: parse real PKCS#10 (agent's PEM) + async PoP verify; decodeCsrWire
  accepts PEM or base64(DER)
- main.ts/env.ts: real issuer when CA_INTERMEDIATE_KEY_PATH present, else dev fallback
- interop.test.ts: oracle proving relay-auth verifyAgentCert accepts the leaf (6 tests)
- deps: @peculiar/x509, reflect-metadata
2026-07-06 20:46:01 +02:00
Yaojia Wang
1a8984e851 feat(deploy): staging bootstrap script + manage-token minter for Phase-1 VPS deploy 2026-07-06 19:37:58 +02:00
Yaojia Wang
d77f1ff62c fix(relay-run): declare package deps for standalone deploy
relay-run imported bare specifiers (control-plane/relay-auth/relay-contracts/relay-e2e/
term-relay + ws/pg/ioredis) with an empty package.json, relying on a hand-populated
node_modules from local dev. Declare them (file: siblings + runtime deps + tsx) so
'npm install' resolves relay-run on a clean host.
2026-07-06 19:25:44 +02:00
Yaojia Wang
5e7e4b22f2 fix(ios): safe-area-inset clearance so tab-bar/key-bar don't obscure terminal
viewport-fit=cover spans the physical screen edges; add --safe-t/--safe-b from
env(safe-area-inset-*) threaded through #tabbar/#term/#keybar/#approvalbar/#searchbox/
#settingspanel. Falls back to 0px (zero regression on non-safe-area platforms).
2026-07-06 16:51:03 +02:00
Yaojia Wang
bfe1be1dfe feat(relay): B7 — close browser DPoP-subprotocol loop + harden staging mint
- Read DPoP proof from term.dpop.<b64u> WS subprotocol (browsers can't set WS headers);
  header wins, else subprotocol. Fail-closed decoder. Unblocks real browser connect.
- F1: rate-limit /auth/mint per-IP via Redis token bucket (salted-hash key, 429 on burst,
  before password compare).
- F2: wire real per-tenant active WS count into activeSessionCount (was hardcoded 0).
- F5: scrub error logs to e.message/.code (no DSN leak, INV9).
relay-run: tsc clean, 92 tests pass (+18). F3/F4 -> Phase 2 backlog.
2026-07-06 16:26:05 +02:00
Yaojia Wang
aa1912b962 feat(relay): Phase1 waves A2-E — server entry, shared-store data plane, agent runtime, deploy artifacts
RELAY-PHASE1 Wave A2/B/C/D/E (12-agent workflow, all tsc-clean, 314/314 tests pass):
- A2: control-plane server.ts entry + boot/redis.ts revocation-bus wiring + start script.
- B1: relay-run shared-store EnforceDeps (relay-auth ports over the SAME Postgres+Redis as P3).
- B2: registry-backed MtlsVerifier (verifyAgentCert, fail-closed, INV14).
- B3: store-backed RouteResolver (subdomain->hostId).
- B4: Redis relay:revocations subscriber -> tunnel teardown (INV12).
- B5: main-phase1.ts production entry (public bind, real TLS, async-mTLS prefetch bridge) + staging /auth/mint.
- B6 (PARTIAL): relay-web operator login + browser DPoP; proof offered via term.dpop.<b64u> subprotocol.
- C: agent dist/cli.js build (esbuild) + runTunnel run-loop + CliDeps.
- D1: same-origin static serve of relay-web/public from the browser WSS.
- E: systemd units + gen-ca/gen-capability-key/issue-tls-cert scripts + deploy/RUNBOOK.md.
Adversarial review: all hard invariants PASS. Follow-ups (B7): close DPoP-subprotocol read on
browser-server (blocks browser connect); rate-limit /auth/mint (F1); wire activeSessionCount (F2);
scrub error logs (F5). Excludes unrelated public/style.css (concurrent iOS job).
2026-07-06 16:13:34 +02:00
Yaojia Wang
95b9cccf07 feat(relay): Phase1 foundation — P3 Postgres store + async capability verifier + compose
RELAY-PHASE1 Wave A (2/3) + E1 infra:
- A1: createPgStores() Postgres adapter for all 9 P3 store ports + runMigrations();
  writable-CTE atomic INV8 status swaps; 0002_routes.sql. 23/23 pg tests, 96.72% cov.
- A3: real capability verifier delegating to relay-auth verifyCapabilityToken; sync->async
  seam across authz/provision/main. Full CP suite 15 files/101 pass, tsc clean.
- E1: deploy/docker-compose.yml (Postgres16+Redis7, loopback-only) + .env.example.
- docs: PLAN_RELAY_PHASE1.md file-level execution spec; PROGRESS_LOG RELAY-PHASE1 section.
2026-07-06 14:51:37 +02:00
111 changed files with 10756 additions and 194 deletions

3
.gitignore vendored
View File

@@ -18,3 +18,6 @@ npm-debug.log*
# test coverage
coverage/
.gstack/
# deploy secrets (RELAY-PHASE1) — .env.example is committed, .env is not
deploy/.env

View File

@@ -13,6 +13,7 @@
"main": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
"build": "esbuild src/main.ts --bundle --platform=node --format=esm --outfile=dist/cli.js --banner:js='#!/usr/bin/env node\nimport{createRequire as __cjs}from\"node:module\";const require=__cjs(import.meta.url);'",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
@@ -26,6 +27,7 @@
"@types/node": "^25.9.3",
"@types/ws": "^8.5.12",
"@vitest/coverage-v8": "^4.1.9",
"esbuild": "^0.28.1",
"typescript": "^6.0.3",
"vitest": "^4.1.9"
}

83
agent/src/cli/deps.ts Normal file
View File

@@ -0,0 +1,83 @@
/**
* 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.
*/
import { execFile } from 'node:child_process'
import { mkdirSync, writeFileSync } from 'node:fs'
import { homedir, userInfo } from 'node:os'
import { dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { CliDeps } 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 { redeemPairingCode } from '../enroll/pair.js'
import { runTunnel } from '../transport/runTunnel.js'
import {
detectPlatform,
installService as installServiceUnit,
uninstallService as uninstallServiceUnit,
type InstallDeps,
type ServicePlatform,
} from '../service/install.js'
/** Resolve this process's own executable path (the bundled `dist/cli.js`) for the service unit. */
function selfBinPath(): string {
return fileURLToPath(import.meta.url)
}
function runCommand(cmd: string, args: readonly string[]): Promise<void> {
return new Promise<void>((resolve, reject) => {
execFile(cmd, [...args], (err) => (err ? reject(err) : resolve()))
})
}
function realInstallDeps(): InstallDeps {
return {
writeFile: (path, content) => {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, content)
},
runCommand,
getuid: () => (typeof process.getuid === 'function' ? process.getuid() : 0),
homedir,
username: () => userInfo().username,
binPath: selfBinPath,
}
}
/** Map the current OS to its service manager, or fail fast with a clear message. */
function requirePlatform(): ServicePlatform {
const platform = detectPlatform(process.platform)
if (platform === null) {
throw new Error(`service install/uninstall is unsupported on platform '${process.platform}'`)
}
return platform
}
/** 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(),
redeem: (cfg: AgentConfig, code, id, ks) => redeemPairingCode(cfg.enrollUrl, code, id, ks),
runTunnel: async (cfg, ks) => {
const handle = await runTunnel(cfg, ks)
const onSignal = (): void => {
void handle.stop()
}
process.once('SIGTERM', onSignal)
process.once('SIGINT', onSignal)
return handle.done
},
installService: (cfg) => installServiceUnit(cfg, requirePlatform(), realInstallDeps()),
uninstallService: () => uninstallServiceUnit(requirePlatform(), { runCommand, homedir }),
print: (line) => {
process.stdout.write(`${line}\n`)
},
}
}

30
agent/src/main.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* CLI bootstrap — PLAN_RELAY_PHASE1 C2. The `dist/cli.js` entrypoint (the `#!/usr/bin/env node`
* shebang is prepended at build time via esbuild `--banner`, NOT here). Reads argv, builds the
* real CliDeps, dispatches through `runCli`, and maps any error to a clean stderr line + exit code
* (usage errors ⇒ 2, everything else ⇒ 1) so no invocation ever crashes with a raw stack trace.
*/
import { parseArgs, runCli, CliUsageError } from './cli.js'
import { createCliDeps } from './cli/deps.js'
async function main(): Promise<number> {
const argv = process.argv.slice(2)
const deps = createCliDeps()
try {
return await runCli(parseArgs(argv), deps)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
process.stderr.write(`web-terminal-agent: ${message}\n`)
return err instanceof CliUsageError ? 2 : 1
}
}
main()
.then((code) => {
process.exitCode = code
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
process.stderr.write(`web-terminal-agent: fatal ${message}\n`)
process.exitCode = 1
})

View File

@@ -0,0 +1,152 @@
/**
* Long-running tunnel supervisor — PLAN_RELAY_PHASE1 C2. Ports the PROVEN cafeDemo assembly
* (dialRelay → holdTunnel → createStreamRouter → dialLoopback, plus heartbeat) into a supervised
* loop that survives disconnects: exponential backoff reconnect (T10 policy), heartbeat liveness
* (T9), and GOAWAY/revocation-aware teardown (T14, INV12 — a revoked host NEVER reconnects).
*
* All IO is injectable via `RunTunnelDeps` so the loop is unit-testable with fakes (see cafeDemo);
* the two-arg `runTunnel(cfg, ks)` default path wires the real `ws` sockets. INV2 is preserved: the
* router splices OPAQUE bytes (identityTransform) — no terminal parsing happens here.
*/
import { WebSocket } from 'ws'
import type { AgentConfig } from '../config/agentConfig.js'
import type { Keystore } from '../keys/keystore.js'
import { createLogger, type Logger } from '../log/logger.js'
import { createRevocationState, applyGoAway } from '../lifecycle/revocation.js'
import { dialRelay, type TlsWsConstructor } from './dial.js'
import { dialLoopback, type DialLoopback, type WsConstructor } from './loopback.js'
import { holdTunnel, type Tunnel } from './tunnel.js'
import { createStreamRouter, identityTransform } from './streamRouter.js'
import { createHeartbeat } from './heartbeat.js'
import { createBackoff, reconnectLoop, type BackoffPolicy, type Sleep } from './backoff.js'
import type { TimerLike, WsLike } from './seams.js'
/** Handle returned by `runTunnel`: stop the supervisor, or await its terminal exit code. */
export interface TunnelHandle {
/** Request graceful shutdown; resolves once the supervisor loop has fully stopped. */
stop(): Promise<void>
/** Resolves with a process exit code when the loop ends (stopped or host revoked ⇒ 0). */
readonly done: Promise<number>
}
/** Injectable seams for the supervisor. All optional; unset fields default to real `ws` IO. */
export interface RunTunnelDeps {
connectRelay(): Promise<WsLike>
dialLoopback: DialLoopback
logger: Logger
timer: TimerLike
sleep: Sleep
backoff: BackoffPolicy
}
const realTimer: TimerLike = {
setTimeout: (cb, ms) => setTimeout(cb, ms),
clearTimeout: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
setInterval: (cb, ms) => setInterval(cb, ms),
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
}
const realSleep: Sleep = (ms) => new Promise<void>((r) => setTimeout(r, ms))
function resolveDeps(cfg: AgentConfig, ks: Keystore, o?: Partial<RunTunnelDeps>): RunTunnelDeps {
return {
connectRelay:
o?.connectRelay ?? (() => dialRelay(cfg, ks, { Ctor: WebSocket as unknown as TlsWsConstructor })),
dialLoopback: o?.dialLoopback ?? dialLoopback(cfg.localTargetUrl, WebSocket as unknown as WsConstructor),
logger: o?.logger ?? createLogger('info'),
timer: o?.timer ?? realTimer,
sleep: o?.sleep ?? realSleep,
backoff: o?.backoff ?? createBackoff({ jitter: true }),
}
}
/**
* Start the supervised tunnel. Returns immediately with a handle; the reconnect loop runs in the
* background. Never awaits the first connection (an unreachable relay would otherwise hang the
* caller with no way to `stop()`).
*/
export async function runTunnel(
cfg: AgentConfig,
ks: Keystore,
overrides?: Partial<RunTunnelDeps>,
): Promise<TunnelHandle> {
const { connectRelay, dialLoopback: dialLb, logger, timer, sleep, backoff } = resolveDeps(cfg, ks, overrides)
let stopped = false
let currentTunnel: Tunnel | null = null
let currentSocket: WsLike | null = null
// INV12: revocation tears the live tunnel down immediately and suppresses all reconnects.
const revocation = createRevocationState(() => currentTunnel?.close())
const shouldStop = (): boolean => stopped || revocation.isRevoked()
async function dialTunnel(): Promise<Tunnel> {
const socket = await connectRelay()
currentSocket = socket
return holdTunnel(socket)
}
/** Run ONE tunnel session; resolves when this session ends (dead heartbeat / close / GOAWAY). */
function runSession(tunnel: Tunnel, socket: WsLike): Promise<void> {
return new Promise<void>((resolve) => {
const router = createStreamRouter(cfg, tunnel, dialLb, identityTransform, logger)
const heartbeat = createHeartbeat(tunnel, { timer })
tunnel.dispatchTo(router, heartbeat)
let settled = false
const endSession = (): void => {
if (settled) return
settled = true
heartbeat.stop()
resolve()
}
heartbeat.onDead(() => {
logger.log('warn', 'heartbeat missed — tunnel presumed down, will reconnect')
tunnel.close()
endSession()
})
socket.on('close', () => endSession())
socket.on('error', () => {
tunnel.close()
endSession()
})
tunnel.onGoAway((reason) => {
const action = applyGoAway(reason, revocation) // 'revoked' ⇒ no reconnect (INV12)
logger.log('info', 'received GOAWAY', { action })
tunnel.close()
endSession()
})
heartbeat.start()
})
}
async function supervise(): Promise<number> {
while (!shouldStop()) {
const tunnel = await reconnectLoop(dialTunnel, backoff, shouldStop, sleep)
if (tunnel === null || currentSocket === null) break
if (shouldStop()) {
// stop()/revoke raced with the in-flight dial — discard the fresh tunnel.
tunnel.close()
break
}
currentTunnel = tunnel
logger.log('info', 'relay tunnel established')
await runSession(tunnel, currentSocket)
currentTunnel = null
currentSocket = null
}
return 0
}
const done = supervise()
return {
async stop(): Promise<void> {
stopped = true
currentTunnel?.close()
await done
},
done,
}
}

View File

@@ -0,0 +1,128 @@
import { describe, expect, it, vi } from 'vitest'
import { decodeMuxFrame, encodeGoaway, encodeMuxFrame, encodeOpen, type MuxOpen } from 'relay-contracts'
import type { AgentConfig } from '../src/config/agentConfig.js'
import type { Keystore } from '../src/keys/keystore.js'
import { createBackoff } from '../src/transport/backoff.js'
import { runTunnel, type RunTunnelDeps } from '../src/transport/runTunnel.js'
import { FakeTimer, FakeWs } from './fixtures/fakes.js'
/**
* C2 supervisor: proves runTunnel ports the cafeDemo assembly into a supervised loop —
* bytes splice both ways, a dead session reconnects, and a `revoked` GOAWAY stops for good (INV12).
*/
const CFG: AgentConfig = {
relayUrl: 'wss://relay/agent',
enrollUrl: 'https://x/enroll',
stateDir: '/tmp/x',
localTargetUrl: 'ws://127.0.0.1:3000',
subdomain: 'host-42',
hostId: 'h-1',
}
const OPEN: MuxOpen = {
streamId: 5,
subdomain: 'host-42',
requestPath: '/term?join=abc',
originHeader: 'https://host-42.term.example.com',
remoteAddrHash: 'x',
capabilityTokenRef: 'jti',
}
const KS = {} as unknown as Keystore // unused when connectRelay/dialLoopback are injected
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
function emitOpen(upstream: FakeWs, open: MuxOpen): void {
const payload = encodeOpen(open)
upstream.emitMessage(
encodeMuxFrame(
{ version: 1, type: 'open', fin: false, rst: false, streamId: open.streamId, payloadLen: payload.length },
payload,
),
)
}
function emitGoAwayRevoked(upstream: FakeWs): void {
const payload = encodeGoaway(0, 'revoked')
upstream.emitMessage(
encodeMuxFrame(
{ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: payload.length },
payload,
),
)
}
function baseDeps(over: Partial<RunTunnelDeps>): Partial<RunTunnelDeps> {
return {
timer: new FakeTimer(), // never auto-fires ⇒ heartbeat is inert during the test
sleep: async () => {},
backoff: createBackoff(),
...over,
}
}
describe('runTunnel supervisor (C2)', () => {
it('splices bytes both ways through the loopback', async () => {
const upstream = new FakeWs()
const loopback = new FakeWs()
const connectRelay = vi.fn(async () => upstream)
const dialLoopback = vi.fn(async () => loopback)
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: dialLoopback as never }))
await flush()
emitOpen(upstream, OPEN)
await flush()
expect(dialLoopback).toHaveBeenCalledWith('/term?join=abc', 'https://host-42.term.example.com')
upstream.emitMessage(encodeMuxFrame({ version: 1, type: 'data', fin: false, rst: false, streamId: 5, payloadLen: 3 }, new Uint8Array([104, 105, 10])))
expect(loopback.sent.at(-1)).toEqual(new Uint8Array([104, 105, 10]))
loopback.emit('message', new Uint8Array([79, 75])) // "OK" echoes back upstream as DATA
const last = decodeMuxFrame(upstream.sent.at(-1)!)
expect(last.header.type).toBe('data')
expect([...last.payload]).toEqual([79, 75])
await handle.stop()
expect(await handle.done).toBe(0)
})
it('reconnects after the tunnel dies', async () => {
const sockets = [new FakeWs(), new FakeWs()]
let i = 0
const connectRelay = vi.fn(async () => sockets[i++]!)
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: async () => new FakeWs() }))
await flush()
expect(connectRelay).toHaveBeenCalledTimes(1)
sockets[0]!.emit('close') // first session dies ⇒ supervisor redials
await flush()
expect(connectRelay).toHaveBeenCalledTimes(2)
await handle.stop()
})
it('a revoked GOAWAY tears down and NEVER reconnects (INV12)', async () => {
const connectRelay = vi.fn(async () => new FakeWs())
let socket: FakeWs | undefined
const wrapped = vi.fn(async () => {
socket = new FakeWs()
return socket
})
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay: wrapped, dialLoopback: async () => new FakeWs() }))
await flush()
expect(wrapped).toHaveBeenCalledTimes(1)
emitGoAwayRevoked(socket!)
await flush()
expect(await handle.done).toBe(0)
expect(wrapped).toHaveBeenCalledTimes(1) // no reconnect after revocation
expect(connectRelay).not.toHaveBeenCalled()
})
it('stop() ends the loop with exit code 0 and does not reconnect', async () => {
const connectRelay = vi.fn(async () => new FakeWs())
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: async () => new FakeWs() }))
await flush()
await handle.stop()
expect(await handle.done).toBe(0)
expect(connectRelay).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,15 @@
-- A1 — routes table (P3-owned). Not present in 0001_init.sql: the RouteStore port is a
-- Redis-like live routing table (INV7 — NOT source of truth). This Postgres adapter needs a
-- durable backing for it. Postgres has no per-key TTL, so we store an explicit `expires_at`
-- and treat `expires_at <= now()` as ABSENT (fail-closed, INV7); expired rows are lazily
-- DELETEd on access. This mirrors `store/memory.ts` memRouteStore() semantics exactly.
--
-- No FK to hosts(host_id): routes are an ephemeral location cache keyed by host_id, decoupled
-- from the ownership source of truth (matches memory.ts, where routes live independently).
CREATE TABLE IF NOT EXISTS routes (
host_id uuid PRIMARY KEY,
relay_node_id text NOT NULL,
updated_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS routes_node_idx ON routes(relay_node_id);

View File

@@ -8,9 +8,12 @@
"name": "control-plane",
"version": "0.0.0",
"dependencies": {
"@peculiar/x509": "^2.0.0",
"fastify": "^4.28.1",
"ioredis": "^5.4.1",
"pg": "^8.12.0",
"reflect-metadata": "^0.2.2",
"relay-auth": "file:../relay-auth",
"relay-contracts": "file:../relay-contracts",
"zod": "^3.23.8"
},
@@ -18,6 +21,23 @@
"@types/node": "^25.9.3",
"@types/pg": "^8.11.10",
"@vitest/coverage-v8": "^4.1.9",
"tsx": "^4.19.2",
"typescript": "^6.0.3",
"vitest": "^4.1.9"
},
"engines": {
"node": ">=18"
}
},
"../relay-auth": {
"version": "0.0.0",
"dependencies": {
"relay-contracts": "file:../relay-contracts",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^25.9.3",
"@vitest/coverage-v8": "^4.1.9",
"typescript": "^6.0.3",
"vitest": "^4.1.9"
},
@@ -133,6 +153,448 @@
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@fastify/ajv-compiler": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz",
@@ -231,6 +693,162 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@peculiar/asn1-cms": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz",
"integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"@peculiar/asn1-x509-attr": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-csr": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz",
"integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz",
"integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pfx": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz",
"integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.8.0",
"@peculiar/asn1-pkcs8": "^2.8.0",
"@peculiar/asn1-rsa": "^2.8.0",
"@peculiar/asn1-schema": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs8": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz",
"integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs9": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz",
"integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.8.0",
"@peculiar/asn1-pfx": "^2.8.0",
"@peculiar/asn1-pkcs8": "^2.8.0",
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"@peculiar/asn1-x509-attr": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz",
"integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz",
"integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==",
"license": "MIT",
"dependencies": {
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz",
"integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509-attr": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz",
"integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/x509": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.0.0.tgz",
"integrity": "sha512-r10lkuy6BNfRmyYdRAfgu6dq0HOmyIV2OLhXWE3gDEPBdX1b8miztJVyX/UxWhLwemNyDP3CLZHpDxDwSY0xaA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-csr": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.0",
"@peculiar/asn1-pkcs9": "^2.6.0",
"@peculiar/asn1-rsa": "^2.6.0",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"pvtsutils": "^1.3.6",
"tslib": "^2.8.1",
"tsyringe": "^4.10.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -765,6 +1383,20 @@
],
"license": "BSD-3-Clause"
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.5",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -884,6 +1516,48 @@
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@@ -1791,6 +2465,24 @@
"node": ">= 0.10"
}
},
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
@@ -1827,6 +2519,16 @@
"node": ">=4"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0"
},
"node_modules/relay-auth": {
"resolved": "../relay-auth",
"link": true
},
"node_modules/relay-contracts": {
"resolved": "../relay-contracts",
"link": true
@@ -2075,9 +2777,44 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.23.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz",
"integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==",
"dev": true,
"license": "0BSD",
"optional": true
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tsyringe": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"license": "MIT",
"dependencies": {
"tslib": "^1.9.3"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "6.0.3",

View File

@@ -9,22 +9,27 @@
},
"main": "src/main.ts",
"scripts": {
"start": "tsx src/server.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage"
},
"dependencies": {
"relay-contracts": "file:../relay-contracts",
"@peculiar/x509": "^2.0.0",
"fastify": "^4.28.1",
"ioredis": "^5.4.1",
"pg": "^8.12.0",
"reflect-metadata": "^0.2.2",
"relay-auth": "file:../relay-auth",
"relay-contracts": "file:../relay-contracts",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^25.9.3",
"@types/pg": "^8.11.10",
"@vitest/coverage-v8": "^4.1.9",
"tsx": "^4.19.2",
"typescript": "^6.0.3",
"vitest": "^4.1.9"
}

View File

@@ -20,9 +20,12 @@ export class AuthzError extends Error {
}
}
/** P5's verifier, injected. Mirrors the frozen `verifyCapabilityToken` signature (§4.3). */
/**
* P5's verifier, injected. Mirrors the frozen `verifyCapabilityToken` signature (§4.3), which is
* ASYNC (Ed25519 verify over WebCrypto) — so this seam returns a Promise and all call sites await.
*/
export interface CapabilityVerifier {
verify(raw: string, expectedAud: string, now: number): CapabilityToken
verify(raw: string, expectedAud: string, now: number): Promise<CapabilityToken>
}
/** Minimal request shape we read for the bearer token (never a body account field). */
@@ -49,19 +52,19 @@ function extractRawToken(req: AuthRequest): string | null {
}
export interface Authorizer {
principalFromRequest(req: AuthRequest): AdminPrincipal
principalFromRequest(req: AuthRequest): Promise<AdminPrincipal>
requireRight(principal: AdminPrincipal, right: CapabilityRight): void
}
export function createAuthorizer(deps: AuthzDeps): Authorizer {
const now = deps.now ?? (() => Math.floor(Date.now() / 1000))
return {
principalFromRequest(req) {
async principalFromRequest(req) {
const raw = extractRawToken(req)
if (raw === null) throw new AuthzError(401, 'missing capability token')
let token: CapabilityToken
try {
token = deps.verifier.verify(raw, deps.expectedAud, now())
token = await deps.verifier.verify(raw, deps.expectedAud, now())
} catch (err: unknown) {
throw new AuthzError(401, `invalid capability token: ${err instanceof Error ? err.message : 'rejected'}`)
}

View File

@@ -15,6 +15,7 @@ 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 { decodeCsrWire } from '../ca/csr.js'
import type { Deprovisioner } from '../deprovision/deprovision.js'
export interface ProvisionDeps {
@@ -30,8 +31,8 @@ const PlanSchema = z.enum(['free', 'personal', 'pro', 'team'])
const StatusSchema = z.object({ status: z.enum(['active', 'suspended']) })
const EnrollSchema = z.object({
code: z.string().min(1),
agentPubkey: z.string().min(1), // base64
csr: z.string().min(1), // base64
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
})
function sendError(reply: FastifyReply, err: unknown): void {
@@ -56,12 +57,12 @@ function assertOwnAccount(principal: AdminPrincipal, pathAccountId: string): voi
export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
return async (app) => {
const principal = (req: FastifyRequest): AdminPrincipal =>
const principal = (req: FastifyRequest): Promise<AdminPrincipal> =>
deps.authorizer.principalFromRequest({ headers: req.headers })
app.post('/accounts', async (req, reply) => {
try {
const p = principal(req)
const p = await principal(req)
deps.authorizer.requireRight(p, 'manage')
const plan: PlanTier = PlanSchema.parse((req.body as { plan?: unknown })?.plan ?? 'free')
const account = await deps.accounts.createAccount(plan)
@@ -73,7 +74,7 @@ export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
app.post('/accounts/:id/pairing-codes', async (req, reply) => {
try {
const p = principal(req)
const p = await principal(req)
deps.authorizer.requireRight(p, 'manage')
assertOwnAccount(p, (req.params as { id: string }).id)
const issued = await deps.pairingIssuer.issuePairingCode(p.accountId)
@@ -85,7 +86,7 @@ export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
app.post('/accounts/:id/status', async (req, reply) => {
try {
const p = principal(req)
const p = await principal(req)
deps.authorizer.requireRight(p, 'manage')
assertOwnAccount(p, (req.params as { id: string }).id)
const { status } = StatusSchema.parse(req.body)
@@ -98,7 +99,7 @@ export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
app.get('/accounts/:id/hosts', async (req, reply) => {
try {
const p = principal(req)
const p = await principal(req)
assertOwnAccount(p, (req.params as { id: string }).id)
const hosts = await deps.hosts.listHosts(p.accountId) // ownership-scoped
await reply.send(hosts.map((h) => ({ ...h, agentPubkey: Buffer.from(h.agentPubkey).toString('base64') })))
@@ -109,7 +110,7 @@ export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
app.delete('/hosts/:hostId', async (req, reply) => {
try {
const p = principal(req)
const p = await principal(req)
deps.authorizer.requireRight(p, 'manage')
// account_id in the body is IGNORED — authz uses ONLY the token principal (INV3).
await deps.deprovisioner.deprovisionHost(p, (req.params as { hostId: string }).hostId)
@@ -126,9 +127,10 @@ export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
const result = await deps.redeemer.redeemPairingCode({
code: body.code,
agentPubkey: new Uint8Array(Buffer.from(body.agentPubkey, 'base64')),
csr: new Uint8Array(Buffer.from(body.csr, 'base64')),
csr: decodeCsrWire(body.csr),
})
await reply.code(201).send({ ...result, hostContentSecret: Buffer.from(result.hostContentSecret).toString('base64') })
// 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) {
sendError(reply, err)
}

View File

@@ -0,0 +1,32 @@
/**
* A2 — ioredis client + `RedisPublisher` adapter for the P3 server entrypoint.
*
* `createRedisClient(url)` constructs the single ioredis connection used for the revocation-bus
* PUBLISH side (the FROZEN `relay:revocations` channel; P1 nodes subscribe — see routing/bus.ts).
* `createRedisPublisher` narrows that client to the minimal `RedisPublisher` surface
* `createRedisRevocationBus` consumes, so the concrete ioredis type never leaks into the bus wiring
* and the seam stays unit-testable with a fake. INV9: no secrets pass through here — the URL is
* owned/validated by `loadEnv` and never logged.
*/
// The default export IS `Redis`; imported by name because this package's tsconfig has no
// `esModuleInterop`, under which a default import of the CJS module resolves to its namespace.
import { Redis } from 'ioredis'
import type { RedisPublisher } from '../routing/bus.js'
/**
* Construct the production ioredis client from a validated connection URL. Connection lifecycle
* (connect/retry) is managed by ioredis; the caller owns `quit()` on shutdown.
*/
export function createRedisClient(url: string): Redis {
return new Redis(url)
}
/**
* Adapt an ioredis client to the `RedisPublisher` seam. Forwards `publish(channel, message)`
* verbatim and returns the subscriber count the driver reports (Promise<number>).
*/
export function createRedisPublisher(redis: Redis): RedisPublisher {
return {
publish: (channel: string, message: string): Promise<number> => redis.publish(channel, message),
}
}

View File

@@ -0,0 +1,38 @@
/**
* A3 — REAL capability verifier, backed by relay-auth's `verifyCapabilityToken` (P5). Replaces the
* fail-closed `refuseAllVerifier` stub. The frozen §4.3 verify signature is ASYNC (Ed25519 verify
* over WebCrypto) and reads its verifying key from relay-auth's startup registry (config/keys.ts),
* never as a parameter — so we (a) adapt it to the async `CapabilityVerifier` seam and (b) load the
* key at boot from the CP env's already-validated pubkey.
*
* KEY BRIDGE (INV9): the CP env var is `CAPABILITY_SIGN_PUBKEY_B64` (base64), parsed+validated in
* `env.ts` to `env.capabilitySignPubkey` (32 raw Ed25519 bytes). relay-auth's own env loader expects
* a DIFFERENT var name (`RELAY_AUTH_VERIFY_PUBKEY`, base64url), so we do NOT use it — we import the
* 32 raw bytes to a non-exportable verify-only CryptoKey via WebCrypto and call relay-auth's
* `configureVerifyKey(CryptoKey)` directly.
*/
import { verifyCapabilityToken, configureVerifyKey } from 'relay-auth'
import type { CapabilityToken } from 'relay-contracts'
import type { CapabilityVerifier } from '../api/authz.js'
/** The real verifier: delegates verbatim to P5's frozen §4.3 `verifyCapabilityToken`. */
export function createCapabilityVerifier(): CapabilityVerifier {
return {
verify(raw: string, expectedAud: string, now: number): Promise<CapabilityToken> {
return verifyCapabilityToken(raw, expectedAud, now)
},
}
}
/**
* Load the §4.3 verifying key into relay-auth's startup registry from the CP env's raw 32-byte
* Ed25519 public key (`env.capabilitySignPubkey`). Non-exportable, `verify`-only. Must run before
* the real verifier serves any request, else `verifyCapabilityToken` throws `KeyConfigError`.
*/
export async function configureCapabilityVerifyKey(pubkeyRaw: Uint8Array): Promise<void> {
// Copy into a fresh ArrayBuffer-backed view (WebCrypto's BufferSource typing / no shared pool).
const bytes = new Uint8Array(pubkeyRaw.length)
bytes.set(pubkeyRaw)
const key = await globalThis.crypto.subtle.importKey('raw', bytes, { name: 'Ed25519' }, false, ['verify'])
configureVerifyKey(key)
}

View File

@@ -1,53 +1,147 @@
/**
* CSR proof-of-possession (INV14). Models a PKCS#10 CSR as `embeddedPub(32) || sig(64)` where
* `sig = Ed25519(privateKey, CSR_CHALLENGE || embeddedPub)`. Verifying the signature against the
* embedded pubkey proves the requester holds the matching private key (the exact property a real
* PKCS#10 self-signature provides) — using builtin crypto only.
* CSR handling (INV14) — REAL PKCS#10 over the agent's Ed25519 identity.
*
* INTEGRATION SEAM: production parses a real PKCS#10 DER (`@peculiar/x509`) and checks the
* self-signature. Swapping it in does not change `signHostLeaf`'s check ORDER or reject path.
* The agent (`agent/src/enroll/csr.ts`) emits a standard PKCS#10 `CertificationRequest` signed by
* its in-process Ed25519 key. This module parses that request with `@peculiar/x509`, verifies its
* self-signature (proof-of-possession — the requester holds the private key for the embedded
* pubkey), and exposes the embedded raw Ed25519 public key so `sign.ts` can gate on
* embeddedPub == agentPubkey. Parsing/verification is FAIL-CLOSED and returns a UNIFORM result: any
* malformed/forged input yields `{ ok: false }` with no distinguishing detail (see `verifyCsrPoP`).
*
* `@peculiar/x509` uses `tsyringe`, which requires the `reflect-metadata` polyfill to be loaded
* before the library — hence the side-effect import on the first line (must stay first).
*/
import { ed25519Sign, ed25519Verify, type KeyObject } from '../util/crypto.js'
import 'reflect-metadata'
import * as x509 from '@peculiar/x509'
import { webcrypto } from 'node:crypto'
import { ed25519Sign, type KeyObject } from '../util/crypto.js'
const CSR_CHALLENGE = new TextEncoder().encode('relay-cp/csr-pop/v1|')
// Ed25519 signing/verification runs on Node's WebCrypto (Ed25519 supported on Node 20+).
x509.cryptoProvider.set(webcrypto)
/** Test/agent helper: build a CSR proving possession of `privateKey` for `embeddedPub`. */
export function buildCsr(privateKey: KeyObject, embeddedPub: Uint8Array): Uint8Array {
const message = concat(CSR_CHALLENGE, embeddedPub)
const sig = ed25519Sign(privateKey, message)
return concat(embeddedPub, sig)
}
// --- Ed25519 SPKI constants -------------------------------------------------------------------
/** Fixed 12-byte SPKI prefix for an Ed25519 SubjectPublicKeyInfo; the raw key is the trailing 32. */
const ED25519_SPKI_PREFIX = Uint8Array.from([
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
])
const ED25519_SPKI_LEN = ED25519_SPKI_PREFIX.length + 32 // 44
const RAW_ED25519_LEN = 32
export interface CsrParts {
readonly embeddedPub: Uint8Array
readonly sig: Uint8Array
}
// --- minimal DER encoding helpers (test/agent-side CSR construction) ---------------------------
/** Parse the modelled CSR bytes. Throws on wrong length. */
export function parseCsr(csr: Uint8Array): CsrParts {
if (csr.length !== 96) throw new Error('malformed csr')
return { embeddedPub: csr.subarray(0, 32), sig: csr.subarray(32, 96) }
}
/**
* Verify CSR proof-of-possession: the embedded pubkey's private key signed the challenge.
* Independent of any registry state (a forged signature is refused even for a registered key).
*/
export function verifyCsrPoP(csr: Uint8Array): { ok: boolean; embeddedPub: Uint8Array } {
let parts: CsrParts
try {
parts = parseCsr(csr)
} catch {
return { ok: false, embeddedPub: new Uint8Array(0) }
function derLen(len: number): Uint8Array {
if (len < 0x80) return Uint8Array.from([len])
const bytes: number[] = []
let n = len
while (n > 0) {
bytes.unshift(n & 0xff)
n >>= 8
}
const message = concat(CSR_CHALLENGE, parts.embeddedPub)
const ok = ed25519Verify(parts.embeddedPub, message, parts.sig)
return { ok, embeddedPub: parts.embeddedPub }
return Uint8Array.from([0x80 | bytes.length, ...bytes])
}
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
const out = new Uint8Array(a.length + b.length)
out.set(a, 0)
out.set(b, a.length)
function tlv(tag: number, value: Uint8Array): Uint8Array {
const len = derLen(value.length)
const out = new Uint8Array(1 + len.length + value.length)
out[0] = tag
out.set(len, 1)
out.set(value, 1 + len.length)
return out
}
function concat(chunks: readonly Uint8Array[]): Uint8Array {
const out = new Uint8Array(chunks.reduce((s, c) => s + c.length, 0))
let off = 0
for (const c of chunks) {
out.set(c, off)
off += c.length
}
return out
}
const SEQUENCE = 0x30
const SET = 0x31
const INTEGER = 0x02
const BIT_STRING = 0x03
const OID = 0x06
const UTF8_STRING = 0x0c
const CONTEXT_0 = 0xa0
const OID_CN = Uint8Array.from([0x55, 0x04, 0x03]) // 2.5.4.3 commonName
const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70]) // 1.3.101.112 Ed25519
function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
const algId = tlv(SEQUENCE, tlv(OID, OID_ED25519))
const pubBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), raw]))
return tlv(SEQUENCE, concat([algId, pubBits]))
}
function nameFromCn(cn: string): Uint8Array {
const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))]))
return tlv(SEQUENCE, tlv(SET, atv))
}
/**
* Build a real PKCS#10 `CertificationRequest` DER for `embeddedPub`, self-signed by `privateKey`
* (Ed25519). Mirrors the agent's on-wire request shape (subject CN, empty attributes). Used by the
* control-plane's own tests to exercise the real parse/verify path; production requests come from
* the agent unchanged.
*/
export function buildCsr(privateKey: KeyObject, embeddedPub: Uint8Array): Uint8Array {
const version = tlv(INTEGER, Uint8Array.from([0x00]))
const requestInfo = tlv(
SEQUENCE,
concat([version, nameFromCn('web-terminal-agent'), spkiFromRawEd25519(embeddedPub), tlv(CONTEXT_0, new Uint8Array(0))]),
)
const signature = ed25519Sign(privateKey, requestInfo)
const sigAlg = tlv(SEQUENCE, tlv(OID, OID_ED25519))
const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature]))
return tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits]))
}
// --- wire decoding + verification --------------------------------------------------------------
const PEM_CSR_RE = /-----BEGIN CERTIFICATE REQUEST-----([\s\S]+?)-----END CERTIFICATE REQUEST-----/
/**
* Decode the `csr` field as it arrives on the `/enroll` wire into raw PKCS#10 DER bytes. Accepts
* BOTH shapes the codebase produces: the agent sends a PEM `CERTIFICATE REQUEST` block, while the
* control-plane's own HTTP tests send `base64(DER)`. Both normalise to the same DER — this is the
* single place the wire encoding is interpreted (boundary validation, coding-style §Input).
*/
export function decodeCsrWire(wire: string): Uint8Array {
const pem = PEM_CSR_RE.exec(wire)
const body = pem !== null ? pem[1]!.replace(/\s+/g, '') : wire.trim()
return new Uint8Array(Buffer.from(body, 'base64'))
}
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
}
/** Extract the raw 32-byte Ed25519 key from a SubjectPublicKeyInfo DER, or null if not Ed25519. */
function rawEd25519FromSpki(spki: Uint8Array): Uint8Array | null {
if (spki.length !== ED25519_SPKI_LEN) return null
for (let i = 0; i < ED25519_SPKI_PREFIX.length; i++) {
if (spki[i] !== ED25519_SPKI_PREFIX[i]) return null
}
return spki.subarray(ED25519_SPKI_PREFIX.length)
}
/**
* Verify CSR proof-of-possession: parse the PKCS#10 DER and check its self-signature against the
* embedded public key. Returns the embedded raw Ed25519 pubkey on success. FAIL-CLOSED and uniform:
* malformed DER, a non-Ed25519 key, or a bad signature all yield `{ ok: false, embeddedPub: [] }`
* with no leak of which check failed. Independent of any registry state.
*/
export async function verifyCsrPoP(csr: Uint8Array): Promise<{ ok: boolean; embeddedPub: Uint8Array }> {
const fail = { ok: false, embeddedPub: new Uint8Array(0) }
try {
const req = new x509.Pkcs10CertificateRequest(toArrayBuffer(csr))
const embeddedPub = rawEd25519FromSpki(new Uint8Array(req.publicKey.rawData))
if (embeddedPub === null || embeddedPub.length !== RAW_ED25519_LEN) return fail
const ok = await req.verify()
return ok ? { ok: true, embeddedPub: new Uint8Array(embeddedPub) } : fail
} catch {
return fail
}
}

View File

@@ -0,0 +1,125 @@
/**
* Real X.509 leaf issuance (INV14). After the shared `assertLeafGate` passes, emit an X.509 v3
* Ed25519 leaf whose subject key is the enrolled agent pubkey and whose only SAN is the host's
* SPIFFE-ID URI — signed by the intermediate Ed25519 key. This is what `relay-auth`'s
* `verifyAgentCert` accepts (it walks leaf → intermediate → self-signed root and parses the
* `URI:spiffe://relay.<domain>/account/<a>/host/<h>` SAN).
*
* The SPIFFE-ID is built with relay-auth's OWN builder (`spiffeIdFor`, deep-imported) so the emitted
* SAN can never drift from the verifier's parser. The intermediate PRIVATE key is a WebCrypto
* `CryptoKey` imported non-extractable (never serialised back out — INV9).
*
* `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 { assertLeafGate, DEFAULT_LEAF_TTL_SEC, type LeafSigner } from './sign.js'
x509.cryptoProvider.set(webcrypto)
/** Backdate notBefore slightly to tolerate small clock skew between control-plane and relay. */
const CLOCK_SKEW_SEC = 60
export interface RealLeafSignerDeps {
readonly hosts: HostStore
/** Intermediate Ed25519 PRIVATE signing key (WebCrypto, non-extractable). */
readonly intermediateKey: CryptoKey
/** Intermediate subject as a Name — used verbatim as the leaf issuer so `checkIssued` matches. */
readonly issuerName: x509.Name
/** DER of [intermediate, root] returned to the agent as its CA bundle (INV14). */
readonly caChainDer: readonly Uint8Array[]
/** Bare trust domain; the SPIFFE builder prepends `relay.`. */
readonly trustDomain: string
readonly leafTtlSec?: number
}
/** Import a raw 32-byte Ed25519 public key as a verifying WebCrypto CryptoKey (via SPKI DER). */
async function importEd25519Public(raw: Uint8Array): Promise<CryptoKey> {
const prefix = Uint8Array.from([
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
])
const spki = new Uint8Array(prefix.length + raw.length)
spki.set(prefix, 0)
spki.set(raw, prefix.length)
return webcrypto.subtle.importKey('spki', spki, { name: 'Ed25519' }, true, ['verify'])
}
/**
* Build the production leaf signer. Every issued leaf: X.509 v3, Ed25519 subject = agentPubkey,
* URI SAN = the host's SPIFFE-ID, CA:false, KeyUsage digitalSignature, EKU clientAuth, validity
* [now-skew, now+ttl]. Returns leaf DER + the injected CA chain DER; `redeem.ts` PEM-wraps both.
*/
export function createRealLeafSigner(deps: RealLeafSignerDeps): LeafSigner {
const ttl = deps.leafTtlSec ?? DEFAULT_LEAF_TTL_SEC
return {
async signHostLeaf(hostId, agentPubkey, csr) {
const host = await assertLeafGate(deps.hosts, hostId, agentPubkey, csr)
const spiffe = spiffeIdFor(host.accountId, host.hostId, deps.trustDomain)
const subjectKey = await importEd25519Public(agentPubkey)
const now = Date.now()
const leaf = await x509.X509CertificateGenerator.create({
serialNumber: randomBytes(16).toString('hex'),
subject: `CN=${host.hostId}`,
issuer: deps.issuerName,
notBefore: new Date(now - CLOCK_SKEW_SEC * 1000),
notAfter: new Date(now + ttl * 1000),
publicKey: subjectKey,
signingKey: deps.intermediateKey,
signingAlgorithm: { name: 'Ed25519' },
extensions: [
new x509.SubjectAlternativeNameExtension([{ type: 'url', value: spiffe }]),
new x509.BasicConstraintsExtension(false, undefined, true),
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.clientAuth]),
],
})
return { cert: new Uint8Array(leaf.rawData), caChain: deps.caChainDer }
},
}
}
export interface LoadRealLeafSignerInput {
readonly hosts: HostStore
/** Intermediate Ed25519 private key, PKCS#8 PEM. */
readonly intermediateKeyPem: string
/** Intermediate certificate, PEM (single block). */
readonly intermediateCertPem: string
/** Self-signed root certificate, PEM (single block). */
readonly rootCertPem: string
readonly trustDomain: string
readonly leafTtlSec?: number
}
function pemToDer(pem: string): ArrayBuffer {
const body = pem.replace(/-----BEGIN [^-]+-----/g, '').replace(/-----END [^-]+-----/g, '').replace(/\s+/g, '')
const bytes = Buffer.from(body, 'base64')
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
}
/**
* Construct a real leaf signer from PEM material (boot path). Imports the intermediate private key
* (non-extractable — never re-serialised, INV9) and derives the issuer Name + CA chain DER from the
* certs. THROWS on unreadable/malformed material so the control-plane fails fast at boot.
*/
export async function loadRealLeafSigner(input: LoadRealLeafSignerInput): Promise<LeafSigner> {
const intermediateKey = await webcrypto.subtle.importKey(
'pkcs8',
pemToDer(input.intermediateKeyPem),
{ name: 'Ed25519' },
false, // non-extractable: the raw private key can never leave the process (INV9)
['sign'],
)
const intermediateCert = new x509.X509Certificate(input.intermediateCertPem)
const rootCert = new x509.X509Certificate(input.rootCertPem)
return createRealLeafSigner({
hosts: input.hosts,
intermediateKey,
issuerName: intermediateCert.subjectName,
caChainDer: [new Uint8Array(intermediateCert.rawData), new Uint8Array(rootCert.rawData)],
trustDomain: input.trustDomain,
...(input.leafTtlSec !== undefined ? { leafTtlSec: input.leafTtlSec } : {}),
})
}

View File

@@ -36,7 +36,7 @@ export function createLeafRenewer(deps: LeafRenewerDeps): LeafRenewer {
// A revoked/absent host cannot renew (INV12 + INV14).
if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered')
const pop = verifyCsrPoP(csr)
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')

View File

@@ -1,13 +1,19 @@
/**
* T8 — bind-time mTLS leaf signer, registry-gated (INV14 registry half). ORDER OF CHECKS (all
* T8 — bind-time mTLS leaf signing, registry-gated (INV14 registry half). ORDER OF CHECKS (all
* must pass, SAME reject path):
* 1. CSR proof-of-possession — verify the CSR self-signature against its embedded pubkey.
* 1. CSR proof-of-possession — verify the PKCS#10 self-signature against its embedded pubkey.
* 2. embedded pubkey == caller-supplied `agentPubkey` (no substitution).
* 3. (hostId, agentPubkey) is an ACTIVE, non-revoked row in the host registry.
* A failure at ANY step rejects identically; the KMS `sign()` is NEVER invoked when any check
* fails. Signing itself is `CaSigner.sign()` (KMS, §3.1) — never a raw private key in memory.
* A failure at ANY step rejects identically; issuance is NEVER reached when any check fails.
*
* Two `LeafSigner` implementations share the `assertLeafGate` gate below:
* - `createLeafSigner` (this file) — DEV/legacy placeholder cert (a signed JSON blob, NOT X.509);
* used only when no real CA material is configured (see `main.ts` fallback).
* - `createRealLeafSigner` (`ca/issue.ts`) — the production issuer that emits a real X.509 v3
* Ed25519 leaf with a SPIFFE SAN, signed by the intermediate key.
*/
import type { HostStore } from '../store/ports.js'
import type { HostRecord } from '../model/records.js'
import type { CaSigner } from '../boot/ca-wiring.js'
import { verifyCsrPoP } from './csr.js'
import { timingSafeEqualBytes, bytesToBase64 } from '../util/bytes.js'
@@ -18,14 +24,6 @@ export class LeafSignError extends Error {
}
}
export interface LeafSignerDeps {
readonly hosts: HostStore
readonly signer: CaSigner
readonly caChainDer: readonly Uint8Array[]
/** Leaf validity in seconds (short-lived, INV14). Default 24h. */
readonly leafTtlSec?: number
}
export interface LeafSigner {
signHostLeaf(
hostId: string,
@@ -34,28 +32,56 @@ export interface LeafSigner {
): Promise<{ cert: Uint8Array; caChain: readonly Uint8Array[] }>
}
const DEFAULT_LEAF_TTL_SEC = 24 * 60 * 60
/** Leaf validity in seconds (short-lived, INV14). Default 24h. */
export const DEFAULT_LEAF_TTL_SEC = 24 * 60 * 60
/**
* Shared registry + proof-of-possession gate (INV14). Runs the three ordered checks and, on
* success, returns the gated host record so the issuer can build the SPIFFE SAN from the
* authoritative `accountId`/`hostId` binding. Throws `LeafSignError` (uniform) on any failure.
*/
export async function assertLeafGate(
hosts: HostStore,
hostId: string,
agentPubkey: Uint8Array,
csr: Uint8Array,
): Promise<HostRecord> {
// 1. proof-of-possession (independent of registry state)
const pop = await verifyCsrPoP(csr)
if (!pop.ok) throw new LeafSignError('csr_rejected')
// 2. no substitution: CSR pubkey must equal the presented agentPubkey
if (!timingSafeEqualBytes(pop.embeddedPub, agentPubkey)) throw new LeafSignError('csr_rejected')
// 3. registry gate: host bound, active, non-revoked, pubkey matches (INV14)
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
}
export interface LeafSignerDeps {
readonly hosts: HostStore
readonly signer: CaSigner
readonly caChainDer: readonly Uint8Array[]
readonly leafTtlSec?: number
}
/**
* DEV/legacy signer — emits a signed JSON placeholder (NOT real X.509). Retained so tests and dev
* boots without configured CA material still exercise the gate/reject path. Production uses
* `createRealLeafSigner` (`ca/issue.ts`).
*/
export function createLeafSigner(deps: LeafSignerDeps): LeafSigner {
const ttl = deps.leafTtlSec ?? DEFAULT_LEAF_TTL_SEC
return {
async signHostLeaf(hostId, agentPubkey, csr) {
// 1. proof-of-possession (independent of registry state)
const pop = verifyCsrPoP(csr)
if (!pop.ok) throw new LeafSignError('csr_rejected')
// 2. no substitution: CSR pubkey must equal the presented agentPubkey
if (!timingSafeEqualBytes(pop.embeddedPub, agentPubkey)) throw new LeafSignError('csr_rejected')
// 3. registry gate: host bound, active, non-revoked, pubkey matches (INV14)
const host = await deps.hosts.get(hostId)
if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered')
if (!timingSafeEqualBytes(host.agentPubkey, agentPubkey)) throw new LeafSignError('not_registered')
const host = await assertLeafGate(deps.hosts, hostId, agentPubkey, csr)
// Only now do we invoke KMS sign() over the to-be-signed leaf.
// Only now do we invoke KMS sign() over the to-be-signed placeholder.
const notAfter = Math.floor(Date.now() / 1000) + ttl
const tbs = new TextEncoder().encode(
JSON.stringify({
v: 1,
hostId,
hostId: host.hostId,
subjectSpki: bytesToBase64(agentPubkey), // subject pubkey == agentPubkey (assertable in tests)
notAfter,
}),

View File

@@ -0,0 +1,48 @@
/**
* A1 — migration runner. Reads every `db/migrations/*.sql` in filename order and executes it
* over the parameterized `query` wrapper (db/pool.ts). All migrations are `IF NOT EXISTS`, so
* `runMigrations` is idempotent and safe to run at every boot / test setup.
*
* `createQuery` ALWAYS routes through the extended (parameterized) protocol — even with `[]`
* params — which rejects multi-statement command strings. So each file is split into its
* top-level statements (on `;`, after stripping `-- line comments`) and each statement is run
* as its own single-command `query(stmt, [])`. Our migrations are plain IF-NOT-EXISTS DDL with
* no `;` inside string literals and no dollar-quoted bodies, so this split is safe.
*/
import { readdir, readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { QueryFn } from './pool.js'
/** Absolute path to `control-plane/db/migrations`, resolved relative to this source file. */
function migrationsDir(): string {
const here = dirname(fileURLToPath(import.meta.url)) // .../control-plane/src/db
return join(here, '..', '..', 'db', 'migrations') // .../control-plane/db/migrations
}
/** Strip `-- line comments`, then split into non-empty top-level statements on `;`. */
export function splitStatements(sql: string): readonly string[] {
const withoutComments = sql
.split('\n')
.map((line) => {
const idx = line.indexOf('--')
return idx >= 0 ? line.slice(0, idx) : line
})
.join('\n')
return withoutComments
.split(';')
.map((stmt) => stmt.trim())
.filter((stmt) => stmt.length > 0)
}
/** Apply all `*.sql` migrations in filename order. Idempotent (every migration is IF NOT EXISTS). */
export async function runMigrations(query: QueryFn): Promise<void> {
const dir = migrationsDir()
const files = (await readdir(dir)).filter((f) => f.endsWith('.sql')).sort()
for (const file of files) {
const sql = await readFile(join(dir, file), 'utf8')
for (const stmt of splitStatements(sql)) {
await query(stmt, [])
}
}
}

View File

@@ -8,6 +8,7 @@
* live KMS call; `loadEnv` validates only the presence/shape of the ref here.
*/
import { z } from 'zod'
import { dirname, join } from 'node:path'
import { base64ToBytes } from './util/bytes.js'
/** Pairing-code TTL default: 10 minutes (§5 T7). */
@@ -24,6 +25,16 @@ export interface ControlPlaneEnv {
readonly caIntermediateKmsKeyRef: string
/** Intermediate cert (public) + chain up to the offline root. */
readonly caIntermediateCertPath: string
/**
* Intermediate Ed25519 PRIVATE key (PKCS#8 PEM) used by the REAL leaf issuer. Optional: when the
* file is present the control-plane issues real X.509 leaves; when absent it falls back to the
* dev placeholder signer. Defaults to `caIntermediateCertPath` with `.cert.pem`/`.pem` → `.key.pem`.
*/
readonly caIntermediateKeyPath: string
/** Self-signed root cert (PEM) for the agent CA bundle. Defaults to a sibling `root.cert.pem`. */
readonly caRootCertPath: string
/** Bare trust domain for the SPIFFE SAN (`spiffe://relay.<domain>/...`). */
readonly relayTrustDomain: string
/** CA bundle used to VERIFY relay-node mTLS client certs (T9 node-auth). */
readonly nodeMtlsTrustBundlePath: string
/** 'term.<domain>' for subdomain assembly. */
@@ -45,12 +56,22 @@ const intWithDefault = (fallback: number) =>
const requiredString = (name: string) =>
z.string({ required_error: `${name} is required` }).trim().min(1, `${name} must not be empty`)
/** Default intermediate KEY path from its CERT path: `*.cert.pem`/`*.pem` → `*.key.pem`. */
function deriveKeyPath(certPath: string): string {
if (certPath.endsWith('.cert.pem')) return certPath.slice(0, -'.cert.pem'.length) + '.key.pem'
if (certPath.endsWith('.pem')) return certPath.slice(0, -'.pem'.length) + '.key.pem'
return certPath + '.key.pem'
}
const EnvSchema = z.object({
PG_URL: requiredString('PG_URL').url('PG_URL must be a valid connection URL'),
REDIS_URL: requiredString('REDIS_URL').url('REDIS_URL must be a valid connection URL'),
CAPABILITY_SIGN_PUBKEY_B64: requiredString('CAPABILITY_SIGN_PUBKEY_B64'),
CA_INTERMEDIATE_KMS_KEY_REF: requiredString('CA_INTERMEDIATE_KMS_KEY_REF'),
CA_INTERMEDIATE_CERT_PATH: requiredString('CA_INTERMEDIATE_CERT_PATH'),
CA_INTERMEDIATE_KEY_PATH: z.string().trim().optional(),
CA_ROOT_CERT_PATH: z.string().trim().optional(),
RELAY_TRUST_DOMAIN: z.string().trim().optional(),
NODE_MTLS_TRUST_BUNDLE_PATH: requiredString('NODE_MTLS_TRUST_BUNDLE_PATH'),
BASE_DOMAIN: requiredString('BASE_DOMAIN'),
HEARTBEAT_TTL_SEC: intWithDefault(15),
@@ -81,12 +102,24 @@ export function loadEnv(source: NodeJS.ProcessEnv): ControlPlaneEnv {
if (capabilitySignPubkey.length !== 32) {
throw new Error('Invalid control-plane env: CAPABILITY_SIGN_PUBKEY_B64 must decode to 32 bytes (Ed25519)')
}
const caIntermediateKeyPath =
e.CA_INTERMEDIATE_KEY_PATH !== undefined && e.CA_INTERMEDIATE_KEY_PATH.length > 0
? e.CA_INTERMEDIATE_KEY_PATH
: deriveKeyPath(e.CA_INTERMEDIATE_CERT_PATH)
const caRootCertPath =
e.CA_ROOT_CERT_PATH !== undefined && e.CA_ROOT_CERT_PATH.length > 0
? e.CA_ROOT_CERT_PATH
: join(dirname(e.CA_INTERMEDIATE_CERT_PATH), 'root.cert.pem')
return {
pgUrl: e.PG_URL,
redisUrl: e.REDIS_URL,
capabilitySignPubkey,
caIntermediateKmsKeyRef: e.CA_INTERMEDIATE_KMS_KEY_REF,
caIntermediateCertPath: e.CA_INTERMEDIATE_CERT_PATH,
caIntermediateKeyPath,
caRootCertPath,
relayTrustDomain:
e.RELAY_TRUST_DOMAIN !== undefined && e.RELAY_TRUST_DOMAIN.length > 0 ? e.RELAY_TRUST_DOMAIN : 'example.com',
nodeMtlsTrustBundlePath: e.NODE_MTLS_TRUST_BUNDLE_PATH,
baseDomain: e.BASE_DOMAIN,
heartbeatTtlSec: e.HEARTBEAT_TTL_SEC,

View File

@@ -12,9 +12,10 @@
* Ed25519 signer (DEV ONLY — NOT a real KMS).
*/
import Fastify, { type FastifyInstance } from 'fastify'
import { existsSync, readFileSync } from 'node:fs'
import type { ControlPlaneEnv } from './env.js'
import { createMemoryStores } from './store/memory.js'
import type { Stores } from './store/ports.js'
import type { Stores, HostStore } from './store/ports.js'
import { createAuditLog } from './audit/log.js'
import { createAccountRegistry } from './registry/accounts.js'
import { createHostRegistry } from './registry/hosts.js'
@@ -22,14 +23,16 @@ 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 { createLeafSigner } from './ca/sign.js'
import { createLeafSigner, type LeafSigner } from './ca/sign.js'
import { loadRealLeafSigner } from './ca/issue.js'
import { createRoutingTable } from './routing/table.js'
import { createInMemoryRevocationBus, type TestableRevocationBus } from './routing/bus.js'
import { createMeteringCollector } from './metering/collect.js'
import { createDeprovisioner } from './deprovision/deprovision.js'
import { createAuthorizer, type CapabilityVerifier } from './api/authz.js'
import { buildRouter } from './api/provision.js'
import { buildCaSigner, inProcessCaSigner, type KmsResolver } from './boot/ca-wiring.js'
import { buildCaSigner, inProcessCaSigner, type KmsResolver, type CaSigner } from './boot/ca-wiring.js'
import { configureCapabilityVerifyKey } from './boot/verifier.js'
import type { RevocationBus } from 'relay-contracts'
export interface ControlPlaneOverrides {
@@ -40,13 +43,47 @@ export interface ControlPlaneOverrides {
readonly caChainDer?: readonly Uint8Array[]
}
/** Default fail-closed verifier — refuses everything until P5 is wired (INV6). */
/**
* Default fail-closed verifier — refuses everything until a real (P5) verifier is injected (INV6).
* Async-shaped to match `CapabilityVerifier`: an async body that throws rejects the promise, so the
* authorizer's `await` surfaces it as a 401.
*/
const refuseAllVerifier: CapabilityVerifier = {
verify() {
async verify(): Promise<never> {
throw new Error('capability verification not configured (P5 integration point)')
},
}
/**
* Choose the leaf signer. When the intermediate PRIVATE key file is present on disk, issue REAL
* X.509 leaves (production); when absent, fall back to the dev placeholder signer so tests and
* key-less dev boots still run. A present-but-unreadable/malformed key FAILS FAST (INV9).
*/
async function buildLeafSigner(
env: ControlPlaneEnv,
hosts: HostStore,
caSigner: CaSigner,
caChainDer: readonly Uint8Array[],
): Promise<LeafSigner> {
if (!existsSync(env.caIntermediateKeyPath)) {
return createLeafSigner({ hosts, signer: caSigner, caChainDer })
}
try {
return await loadRealLeafSigner({
hosts,
intermediateKeyPem: readFileSync(env.caIntermediateKeyPath, 'utf8'),
intermediateCertPem: readFileSync(env.caIntermediateCertPath, 'utf8'),
rootCertPem: readFileSync(env.caRootCertPath, 'utf8'),
trustDomain: env.relayTrustDomain,
})
} catch (err: unknown) {
// Never echo key material — only that loading failed and which path shape was configured (INV9).
throw new Error(
`failed to load CA leaf-signing material: ${err instanceof Error ? err.message : 'unknown'}`,
)
}
}
/** In-process KMS resolver — DEV ONLY. Production injects a real non-exportable KMS key (§3.1). */
function devKmsResolver(): KmsResolver {
const signer = inProcessCaSigner()
@@ -72,7 +109,7 @@ export async function buildControlPlane(
const subdomains = createSubdomainAssigner({ subdomains: stores.subdomains, audit })
const caSigner = await buildCaSigner(env, overrides.kmsResolver ?? devKmsResolver())
const leafSigner = createLeafSigner({ hosts: stores.hosts, signer: caSigner, caChainDer })
const leafSigner = await buildLeafSigner(env, stores.hosts, caSigner, caChainDer)
const pairingIssuer = createPairingIssuer({ pairing: stores.pairing, pairingTtlSec: env.pairingTtlSec, audit })
const redeemer = createPairingRedeemer({
@@ -92,8 +129,16 @@ export async function buildControlPlane(
const deprovisioner = createDeprovisioner({ hosts, routing })
void bus // reserved for the node-coordinator / revoker wiring (drain + revoke publishers)
// A real (injected) verifier is P5's async `verifyCapabilityToken`, which reads its Ed25519 key
// from relay-auth's startup registry — so load that key from env at boot. The fail-closed default
// never reads a key, so leave relay-auth's registry untouched when nothing is injected (INV6).
const verifier = overrides.verifier ?? refuseAllVerifier
if (overrides.verifier !== undefined) {
await configureCapabilityVerifyKey(env.capabilitySignPubkey)
}
const authorizer = createAuthorizer({
verifier: overrides.verifier ?? refuseAllVerifier,
verifier,
expectedAud: env.baseDomain,
})

View File

@@ -89,7 +89,7 @@ export function createPairingRedeemer(deps: RedeemDeps): PairingRedeemer {
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 = verifyCsrPoP(input.csr)
const pop = await verifyCsrPoP(input.csr)
if (!pop.ok || !timingSafeEqualBytes(pop.embeddedPub, input.agentPubkey)) {
await deps.pairing.registerFailure(codeHash)
throw new RedeemError('bad_csr')

View File

@@ -0,0 +1,99 @@
/**
* A2 — P3 control-plane server entrypoint. Wires validated env → Postgres pool/stores → migrations →
* ioredis revocation bus → real capability verifier → Fastify app, then binds the ADMIN provisioning
* API to a LOOPBACK host by default (CP_BIND_HOST). This process owns no terminal/PTY state, so
* PTY!=WS restart-safety (INV7) is unaffected: it only serves provisioning/pairing/revocation.
*
* Config is env-only (no hardcoded hosts/ports/secrets): control-plane secrets via `loadEnv`
* (fail-fast, never echoes values — INV9); bind address via CP_BIND_HOST / CP_BIND_PORT.
* accountId is only ever derived from the authenticated capability token inside buildControlPlane
* (INV3) — this entrypoint never fabricates identity.
*/
import { loadEnv } from './env.js'
import { createPgPool, createQuery } from './db/pool.js'
import { createPgStores } from './store/pg.js'
import { runMigrations } from './db/migrate.js'
import { createRedisRevocationBus } from './routing/bus.js'
import { createCapabilityVerifier, configureCapabilityVerifyKey } from './boot/verifier.js'
import { createRedisClient, createRedisPublisher } from './boot/redis.js'
import { buildControlPlane } from './main.js'
/** Admin API binds to loopback by default — never expose the provisioning API on a public interface. */
const DEFAULT_CP_BIND_HOST = '127.0.0.1'
const DEFAULT_CP_BIND_PORT = 8080
const MAX_TCP_PORT = 65535
function resolveBindHost(source: NodeJS.ProcessEnv): string {
const raw = source.CP_BIND_HOST?.trim()
return raw === undefined || raw === '' ? DEFAULT_CP_BIND_HOST : raw
}
function resolveBindPort(source: NodeJS.ProcessEnv): number {
const raw = source.CP_BIND_PORT?.trim()
if (raw === undefined || raw === '') return DEFAULT_CP_BIND_PORT
const port = Number(raw)
if (!Number.isInteger(port) || port < 1 || port > MAX_TCP_PORT) {
throw new Error('Invalid CP_BIND_PORT: must be an integer 1-65535')
}
return port
}
async function main(): Promise<void> {
const env = loadEnv(process.env)
// Postgres: parameterized-only query wrapper + PG-backed repository ports, migrations applied
// idempotently at boot.
const pool = createPgPool(env.pgUrl)
const query = createQuery(pool)
const stores = createPgStores(query)
await runMigrations(query)
// Redis: single client drives the revocation-bus publish side (relay:revocations).
const redis = createRedisClient(env.redisUrl)
const bus = createRedisRevocationBus(createRedisPublisher(redis))
// Capability verifier: load the §4.3 verifying key into relay-auth's registry BEFORE serving,
// then delegate to relay-auth's async verifyCapabilityToken (INV3).
await configureCapabilityVerifyKey(env.capabilitySignPubkey)
const verifier = createCapabilityVerifier()
const { app } = await buildControlPlane(env, { stores, bus, verifier })
const host = resolveBindHost(process.env)
const port = resolveBindPort(process.env)
let shuttingDown = false
const shutdown = async (signal: string): Promise<void> => {
if (shuttingDown) return
shuttingDown = true
process.stdout.write(`control-plane received ${signal}, shutting down\n`)
try {
await app.close()
} finally {
// Best-effort Redis close; force-disconnect if a graceful QUIT cannot complete.
await redis.quit().catch(() => redis.disconnect())
await pool.end()
}
}
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.once(signal, () => {
void shutdown(signal).then(
() => process.exit(0),
(err: unknown) => {
process.stderr.write(`shutdown failed: ${err instanceof Error ? err.message : String(err)}\n`)
process.exit(1)
},
)
})
}
await app.listen({ host, port })
// Startup breadcrumb — bind address only, never secrets (INV9).
process.stdout.write(`control-plane listening on ${host}:${port}\n`)
}
main().catch((err: unknown) => {
process.stderr.write(`control-plane failed to start: ${err instanceof Error ? err.message : String(err)}\n`)
process.exit(1)
})

View File

@@ -0,0 +1,600 @@
/**
* A1 — Postgres adapter for the repository ports (store/ports.ts). Each store maps its port
* methods to PARAMETERIZED SQL over the injected `query` wrapper (db/pool.ts) — values ALWAYS
* travel as bound params, never string-interpolated (SQLi structurally impossible, §7).
*
* This adapter MUST reproduce store/memory.ts observable semantics EXACTLY:
* - INV8 versioning: `swapStatus` appends a `*_status_versions` row AND bumps the single-row
* `status`/`status_version` pointer ATOMICALLY. Postgres has real transactions, but we get
* the same all-or-nothing with a single writable-CTE statement (no explicit tx / PoolClient).
* - `insert` duplicates throw (PK / UNIQUE violation, pg code 23505 → `throw new Error`).
* - `casRedeem` is a single-winner CAS of `redeemed_at` from null.
* - `RouteStore` fails closed on TTL: `expires_at <= now()` ⇒ absent (INV7), lazily DELETEd.
* - metering/audit are append-only with inclusive `[from,to]` time-window queries.
*
* Timestamps: DB columns are `timestamptz` (node-pg returns them as JS `Date`); records use ISO
* strings. `toIso` normalizes Date|string → ISO so returned records match memory.ts string shapes.
*/
import type {
AccountRecord,
AccountStatus,
AccountStatusVersionRow,
HostRecord,
HostStatus,
HostStatusVersionRow,
MeteringSampleRow,
PairingCodeRecord,
PlanTier,
RouteEntry,
SessionRecord,
} from '../model/records.js'
import type { QueryFn } from '../db/pool.js'
import type {
AccountStore,
AuditRow,
AuditStore,
CasOutcome,
HostStore,
MeteringStore,
NodeRow,
NodeStatus,
NodeStore,
PairingRow,
PairingStore,
RouteStore,
SessionStore,
Stores,
SubdomainStore,
} from './ports.js'
// ---- helpers ----------------------------------------------------------------------------------
const toIso = (v: Date | string): string => (v instanceof Date ? v : new Date(v)).toISOString()
const toIsoN = (v: Date | string | null): string | null => (v === null ? null : toIso(v))
/** pg unique/PK violation. */
function isUniqueViolation(err: unknown): err is { code: string; constraint?: string } {
return typeof err === 'object' && err !== null && (err as { code?: unknown }).code === '23505'
}
// ---- raw DB row shapes ------------------------------------------------------------------------
interface AccountRow {
account_id: string
plan: PlanTier
created_at: Date | string
status: AccountStatus
}
interface HostRow {
host_id: string
account_id: string
subdomain: string
agent_pubkey: Buffer
enroll_fpr: string
status: HostStatus
last_seen: Date | string
created_at: Date | string
revoked_at: Date | string | null
}
interface SessionRow {
session_id: string
host_id: string
account_id: string
created_at: Date | string
last_attach_at: Date | string
}
interface AccountVersionRow {
account_id: string
version: number
status: AccountStatus
changed_at: Date | string
changed_by: string
}
interface HostVersionRow {
host_id: string
version: number
status: HostStatus
revoked_at: Date | string | null
changed_at: Date | string
changed_by: string
}
function mapAccount(r: AccountRow): AccountRecord {
return { accountId: r.account_id, plan: r.plan, createdAt: toIso(r.created_at), status: r.status }
}
function mapHost(r: HostRow): HostRecord {
return {
hostId: r.host_id,
accountId: r.account_id,
subdomain: r.subdomain,
agentPubkey: new Uint8Array(r.agent_pubkey),
enrollFpr: r.enroll_fpr,
status: r.status,
lastSeen: toIso(r.last_seen),
createdAt: toIso(r.created_at),
revokedAt: toIsoN(r.revoked_at),
}
}
// ---- AccountStore -----------------------------------------------------------------------------
function pgAccountStore(query: QueryFn): AccountStore {
return {
async insert(rec) {
// Writable CTE: create the row AND its INV8 version-1 companion in ONE statement.
// version-1 changed_at = created_at, changed_by = 'system' (matches memory.ts).
try {
await query(
`WITH ins AS (
INSERT INTO accounts (account_id, plan, created_at, status, status_version)
VALUES ($1, $2, $3, $4, 1)
RETURNING account_id, created_at, status
)
INSERT INTO account_status_versions (account_id, version, status, changed_at, changed_by)
SELECT account_id, 1, status, created_at, 'system' FROM ins`,
[rec.accountId, rec.plan, rec.createdAt, rec.status],
)
} catch (err) {
if (isUniqueViolation(err)) throw new Error('duplicate accountId')
throw err
}
},
async get(accountId) {
const rows = await query<AccountRow>(
`SELECT account_id, plan, created_at, status FROM accounts WHERE account_id = $1`,
[accountId],
)
const row = rows[0]
return row === undefined ? null : mapAccount(row)
},
async swapStatus(accountId, status, changedBy) {
// Atomic INV8: bump pointer + append version row in one writable-CTE statement.
const rows = await query<AccountRow>(
`WITH upd AS (
UPDATE accounts SET status = $2, status_version = status_version + 1
WHERE account_id = $1
RETURNING account_id, plan, created_at, status, status_version
),
ins AS (
INSERT INTO account_status_versions (account_id, version, status, changed_by)
SELECT account_id, status_version, status, $3 FROM upd
)
SELECT account_id, plan, created_at, status FROM upd`,
[accountId, status, changedBy],
)
const row = rows[0]
if (row === undefined) throw new Error('account not found')
return mapAccount(row)
},
async versions(accountId) {
const rows = await query<AccountVersionRow>(
`SELECT account_id, version, status, changed_at, changed_by
FROM account_status_versions WHERE account_id = $1 ORDER BY version ASC`,
[accountId],
)
return rows.map(
(r): AccountStatusVersionRow => ({
accountId: r.account_id,
version: r.version,
status: r.status,
changedAt: toIso(r.changed_at),
changedBy: r.changed_by,
}),
)
},
}
}
// ---- HostStore --------------------------------------------------------------------------------
function pgHostStore(query: QueryFn): HostStore {
return {
async insert(rec) {
try {
await query(
`WITH ins AS (
INSERT INTO hosts
(host_id, account_id, subdomain, agent_pubkey, enroll_fpr, status, last_seen, created_at, revoked_at, status_version)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 1)
RETURNING host_id, created_at, status, revoked_at
)
INSERT INTO host_status_versions (host_id, version, status, revoked_at, changed_at, changed_by)
SELECT host_id, 1, status, revoked_at, created_at, 'system' FROM ins`,
[
rec.hostId,
rec.accountId,
rec.subdomain,
Buffer.from(rec.agentPubkey),
rec.enrollFpr,
rec.status,
rec.lastSeen,
rec.createdAt,
rec.revokedAt,
],
)
} catch (err) {
if (isUniqueViolation(err)) {
// subdomain UNIQUE is the subdomain single-winner substrate; PK is host_id.
throw new Error(err.constraint?.includes('subdomain') ? 'duplicate subdomain' : 'duplicate hostId')
}
throw err
}
},
async get(hostId) {
const rows = await query<HostRow>(`SELECT * FROM hosts WHERE host_id = $1`, [hostId])
const row = rows[0]
return row === undefined ? null : mapHost(row)
},
async getBySubdomain(subdomain) {
const rows = await query<HostRow>(`SELECT * FROM hosts WHERE subdomain = $1`, [subdomain])
const row = rows[0]
return row === undefined ? null : mapHost(row)
},
async listByAccount(accountId) {
const rows = await query<HostRow>(`SELECT * FROM hosts WHERE account_id = $1 ORDER BY created_at ASC`, [
accountId,
])
return rows.map(mapHost)
},
async swapStatus(hostId, status, revokedAt, changedBy) {
const rows = await query<HostRow>(
`WITH upd AS (
UPDATE hosts SET status = $2, revoked_at = $3, status_version = status_version + 1
WHERE host_id = $1
RETURNING host_id, account_id, subdomain, agent_pubkey, enroll_fpr, status, last_seen, created_at, revoked_at, status_version
),
ins AS (
INSERT INTO host_status_versions (host_id, version, status, revoked_at, changed_by)
SELECT host_id, status_version, status, revoked_at, $4 FROM upd
)
SELECT host_id, account_id, subdomain, agent_pubkey, enroll_fpr, status, last_seen, created_at, revoked_at FROM upd`,
[hostId, status, revokedAt, changedBy],
)
const row = rows[0]
if (row === undefined) throw new Error('host not found')
return mapHost(row)
},
async touchLastSeen(hostId, ts) {
// Advisory cache — NO version row (INV7). Silent no-op if absent (matches memory.ts).
await query(`UPDATE hosts SET last_seen = $2 WHERE host_id = $1`, [hostId, ts])
},
async versions(hostId) {
const rows = await query<HostVersionRow>(
`SELECT host_id, version, status, revoked_at, changed_at, changed_by
FROM host_status_versions WHERE host_id = $1 ORDER BY version ASC`,
[hostId],
)
return rows.map(
(r): HostStatusVersionRow => ({
hostId: r.host_id,
version: r.version,
status: r.status,
revokedAt: toIsoN(r.revoked_at),
changedAt: toIso(r.changed_at),
changedBy: r.changed_by,
}),
)
},
}
}
// ---- SessionStore -----------------------------------------------------------------------------
function pgSessionStore(query: QueryFn): SessionStore {
return {
async insert(rec) {
try {
await query(
`INSERT INTO sessions (session_id, host_id, account_id, created_at, last_attach_at)
VALUES ($1, $2, $3, $4, $5)`,
[rec.sessionId, rec.hostId, rec.accountId, rec.createdAt, rec.lastAttachAt],
)
} catch (err) {
if (isUniqueViolation(err)) throw new Error('duplicate sessionId')
throw err
}
},
async get(sessionId) {
const rows = await query<SessionRow>(`SELECT * FROM sessions WHERE session_id = $1`, [sessionId])
const row = rows[0]
if (row === undefined) return null
return {
sessionId: row.session_id,
hostId: row.host_id,
accountId: row.account_id,
createdAt: toIso(row.created_at),
lastAttachAt: toIso(row.last_attach_at),
}
},
}
}
// ---- SubdomainStore ---------------------------------------------------------------------------
/**
* No separate reservations table exists in the schema — the `hosts.subdomain UNIQUE` constraint
* is the single-winner substrate (the true winner is decided at host insert). So `reserve`/`isTaken`
* just report whether a host already owns the label (matches memory.ts intent). Two concurrent
* `reserve()`s can both see "free" before any host row exists; that is fine, because the actual
* single-winner is enforced when the losing insert hits the UNIQUE violation (INV1).
*/
function pgSubdomainStore(query: QueryFn): SubdomainStore {
const taken = async (subdomain: string): Promise<boolean> => {
const rows = await query<{ one: number }>(`SELECT 1 AS one FROM hosts WHERE subdomain = $1 LIMIT 1`, [
subdomain,
])
return rows.length > 0
}
return {
async reserve(subdomain) {
return !(await taken(subdomain))
},
async isTaken(subdomain) {
return taken(subdomain)
},
}
}
// ---- PairingStore -----------------------------------------------------------------------------
interface PairingCodeRow {
code_hash: string
account_id: string
expires_at: Date | string
redeemed_at: Date | string | null
redeem_attempts: number
}
function pgPairingStore(query: QueryFn): PairingStore {
return {
async insert(rec) {
try {
await query(
`INSERT INTO pairing_codes (code_hash, account_id, expires_at, redeemed_at, redeem_attempts)
VALUES ($1, $2, $3, $4, 0)`,
[rec.codeHash, rec.accountId, rec.expiresAt, rec.redeemedAt],
)
} catch (err) {
if (isUniqueViolation(err)) throw new Error('duplicate code_hash')
throw err
}
},
async get(codeHash): Promise<PairingRow | null> {
const rows = await query<PairingCodeRow>(`SELECT * FROM pairing_codes WHERE code_hash = $1`, [codeHash])
const row = rows[0]
if (row === undefined) return null
const record: PairingCodeRecord = {
codeHash: row.code_hash,
accountId: row.account_id,
expiresAt: toIso(row.expires_at),
redeemedAt: toIsoN(row.redeemed_at),
}
return { record, redeemAttempts: row.redeem_attempts }
},
async casRedeem(codeHash, nowIso): Promise<CasOutcome> {
// Single-winner CAS: only the row with redeemed_at IS NULL is updated; exactly one wins.
const won = await query<{ code_hash: string }>(
`UPDATE pairing_codes SET redeemed_at = $2
WHERE code_hash = $1 AND redeemed_at IS NULL
RETURNING code_hash`,
[codeHash, nowIso],
)
if (won.length === 1) return 'ok'
// 0 rows: either already redeemed or unknown — distinguish by existence.
const exists = await query<{ one: number }>(`SELECT 1 AS one FROM pairing_codes WHERE code_hash = $1`, [
codeHash,
])
return exists.length > 0 ? 'already_redeemed' : 'unknown'
},
async registerFailure(codeHash) {
const rows = await query<{ redeem_attempts: number }>(
`UPDATE pairing_codes SET redeem_attempts = redeem_attempts + 1
WHERE code_hash = $1
RETURNING redeem_attempts`,
[codeHash],
)
const row = rows[0]
return row === undefined ? 0 : row.redeem_attempts // 0 for unknown code (matches memory.ts)
},
}
}
// ---- RouteStore -------------------------------------------------------------------------------
interface RouteRow {
host_id: string
relay_node_id: string
updated_at: Date | string
}
function pgRouteStore(query: QueryFn): RouteStore {
const mapEntry = (r: RouteRow): RouteEntry => ({ relayNodeId: r.relay_node_id, updatedAt: toIso(r.updated_at) })
return {
async set(hostId, entry, ttlSec) {
// Postgres has no per-key TTL: store an explicit expires_at = now() + ttl (server clock).
await query(
`INSERT INTO routes (host_id, relay_node_id, updated_at, expires_at)
VALUES ($1, $2, $3, now() + make_interval(secs => $4))
ON CONFLICT (host_id) DO UPDATE SET
relay_node_id = EXCLUDED.relay_node_id,
updated_at = EXCLUDED.updated_at,
expires_at = EXCLUDED.expires_at`,
[hostId, entry.relayNodeId, entry.updatedAt, ttlSec],
)
},
async refreshTtl(hostId, ttlSec) {
// Refresh only a still-live key (fail closed on already-expired/absent, INV7).
const rows = await query<{ host_id: string }>(
`UPDATE routes SET expires_at = now() + make_interval(secs => $2)
WHERE host_id = $1 AND expires_at > now()
RETURNING host_id`,
[hostId, ttlSec],
)
return rows.length === 1
},
async get(hostId) {
// Lazily reap the expired row, then a surviving row is guaranteed live (INV7 fail-closed).
await query(`DELETE FROM routes WHERE host_id = $1 AND expires_at <= now()`, [hostId])
const rows = await query<RouteRow>(
`SELECT host_id, relay_node_id, updated_at FROM routes WHERE host_id = $1`,
[hostId],
)
const row = rows[0]
return row === undefined ? null : mapEntry(row)
},
async delete(hostId) {
await query(`DELETE FROM routes WHERE host_id = $1`, [hostId])
},
async listByNode(nodeId) {
await query(`DELETE FROM routes WHERE expires_at <= now()`, [])
const rows = await query<RouteRow>(
`SELECT host_id, relay_node_id, updated_at FROM routes
WHERE relay_node_id = $1 AND expires_at > now() ORDER BY host_id ASC`,
[nodeId],
)
return rows.map((r) => ({ hostId: r.host_id, entry: mapEntry(r) }))
},
}
}
// ---- NodeStore --------------------------------------------------------------------------------
interface NodeDbRow {
node_id: string
addr: string
status: NodeStatus
last_seen: Date | string
}
function pgNodeStore(query: QueryFn): NodeStore {
return {
async upsert(row) {
await query(
`INSERT INTO relay_nodes (node_id, addr, status, last_seen)
VALUES ($1, $2, $3, $4)
ON CONFLICT (node_id) DO UPDATE SET
addr = EXCLUDED.addr, status = EXCLUDED.status, last_seen = EXCLUDED.last_seen`,
[row.nodeId, row.addr, row.status, row.lastSeen],
)
},
async get(nodeId) {
const rows = await query<NodeDbRow>(`SELECT * FROM relay_nodes WHERE node_id = $1`, [nodeId])
const row = rows[0]
if (row === undefined) return null
return { nodeId: row.node_id, addr: row.addr, status: row.status, lastSeen: toIso(row.last_seen) }
},
async setStatus(nodeId, status) {
const rows = await query<{ node_id: string }>(
`UPDATE relay_nodes SET status = $2 WHERE node_id = $1 RETURNING node_id`,
[nodeId, status],
)
if (rows.length === 0) throw new Error('node not found')
},
async touch(nodeId, ts) {
// Silent no-op if absent (matches memory.ts).
await query(`UPDATE relay_nodes SET last_seen = $2 WHERE node_id = $1`, [nodeId, ts])
},
async delete(nodeId) {
await query(`DELETE FROM relay_nodes WHERE node_id = $1`, [nodeId])
},
}
}
// ---- MeteringStore ----------------------------------------------------------------------------
interface MeteringDbRow {
host_id: string
account_id: string
node_id: string
concurrent_viewers: number
sampled_at: Date | string
}
function pgMeteringStore(query: QueryFn): MeteringStore {
return {
async append(row) {
await query(
`INSERT INTO metering_samples (host_id, account_id, node_id, concurrent_viewers, sampled_at)
VALUES ($1, $2, $3, $4, $5)`,
[row.hostId, row.accountId, row.nodeId, row.concurrentViewers, row.sampledAt],
)
},
async query(accountId, fromIso, toIso2) {
const rows = await query<MeteringDbRow>(
`SELECT host_id, account_id, node_id, concurrent_viewers, sampled_at
FROM metering_samples
WHERE account_id = $1 AND sampled_at >= $2 AND sampled_at <= $3
ORDER BY id ASC`,
[accountId, fromIso, toIso2],
)
return rows.map(
(r): MeteringSampleRow => ({
hostId: r.host_id,
accountId: r.account_id,
nodeId: r.node_id,
concurrentViewers: r.concurrent_viewers,
sampledAt: toIso(r.sampled_at),
}),
)
},
}
}
// ---- AuditStore -------------------------------------------------------------------------------
interface AuditDbRow {
action: string
principal_id: string
account_id: string
host_id: string | null
ts: Date | string
meta: Record<string, string>
}
function pgAuditStore(query: QueryFn): AuditStore {
return {
async append(row) {
await query(
`INSERT INTO audit_log (action, principal_id, account_id, host_id, ts, meta)
VALUES ($1, $2, $3, $4, $5, $6::jsonb)`,
[row.action, row.principalId, row.accountId, row.hostId, row.ts, JSON.stringify(row.meta)],
)
},
async query(accountId, fromIso, toIso2) {
const rows = await query<AuditDbRow>(
`SELECT action, principal_id, account_id, host_id, ts, meta
FROM audit_log
WHERE account_id = $1 AND ts >= $2 AND ts <= $3
ORDER BY id ASC`,
[accountId, fromIso, toIso2],
)
return rows.map(
(r): AuditRow => ({
action: r.action,
principalId: r.principal_id,
accountId: r.account_id,
hostId: r.host_id,
ts: toIso(r.ts),
meta: r.meta,
}),
)
},
}
}
// ---- assembly ---------------------------------------------------------------------------------
/** Build a full Postgres-backed store set (all ports mapped to parameterized SQL over `query`). */
export function createPgStores(query: QueryFn): Stores {
return {
accounts: pgAccountStore(query),
hosts: pgHostStore(query),
sessions: pgSessionStore(query),
subdomains: pgSubdomainStore(query),
pairing: pgPairingStore(query),
routes: pgRouteStore(query),
nodes: pgNodeStore(query),
metering: pgMeteringStore(query),
audit: pgAuditStore(query),
}
}

View File

@@ -24,7 +24,7 @@ const env = loadEnv({
})
const verifier: CapabilityVerifier = {
verify(raw, expectedAud, now): CapabilityToken {
async verify(raw, expectedAud, now): Promise<CapabilityToken> {
if (raw !== 'tokenA') throw new Error('invalid token')
return { sub: ACCOUNT_A, aud: expectedAud, host: 'x', rights: ['manage'] as CapabilityRight[], iat: now, exp: now + 3600, jti: 'jti-A' }
},

View File

@@ -25,8 +25,9 @@ const env = loadEnv({
})
// Fake P5 verifier: 'tokenA'→account A (manage), 'attachA'→account A (attach only). Else reject.
// Async to match the `CapabilityVerifier` seam (real §4.3 verify is async).
const verifier: CapabilityVerifier = {
verify(raw, expectedAud, now): CapabilityToken {
async verify(raw, expectedAud, now): Promise<CapabilityToken> {
const base = { aud: expectedAud, host: 'x', iat: now, exp: now + 3600, jti: `jti-${raw}` }
if (raw === 'tokenA') return { ...base, sub: ACCOUNT_A, rights: ['manage'] as CapabilityRight[] }
if (raw === 'attachA') return { ...base, sub: ACCOUNT_A, rights: ['attach'] as CapabilityRight[] }

View File

@@ -0,0 +1,59 @@
/**
* A2 — unit tests for the ioredis → RedisPublisher adapter (boot/redis.ts). The adapter is the only
* non-trivial logic in the server entrypoint's Redis wiring: it must forward publish(channel, message)
* verbatim to the underlying client and surface the driver's subscriber-count result unchanged. The
* live `createRedisClient` (a thin `new Redis(url)`) needs a real broker and is covered by the boot
* smoke, not here.
*/
import { describe, test, expect } from 'vitest'
import type { Redis } from 'ioredis'
import { createRedisPublisher } from '../src/boot/redis.js'
import { createRedisRevocationBus } from '../src/routing/bus.js'
import { RELAY_REVOCATIONS_CHANNEL, type KillSignal } from 'relay-contracts'
/** Minimal ioredis stand-in recording publish calls; cast to Redis at the seam (only `publish` is used). */
function fakeRedis(returnValue = 1): { calls: Array<[string, string]>; client: Redis } {
const calls: Array<[string, string]> = []
const client = {
publish: async (channel: string, message: string): Promise<number> => {
calls.push([channel, message])
return returnValue
},
} as unknown as Redis
return { calls, client }
}
describe('A2 createRedisPublisher (RedisPublisher adapter)', () => {
test('forwards channel + message verbatim and returns the driver subscriber count', async () => {
// Arrange
const { calls, client } = fakeRedis(3)
const publisher = createRedisPublisher(client)
// Act
const receivers = await publisher.publish('relay:revocations', 'payload')
// Assert
expect(receivers).toBe(3)
expect(calls).toEqual([['relay:revocations', 'payload']])
})
test('drives createRedisRevocationBus onto the FROZEN relay:revocations channel with JSON payload', async () => {
// Arrange
const { calls, client } = fakeRedis()
const bus = createRedisRevocationBus(createRedisPublisher(client))
const signal: KillSignal = {
scope: { kind: 'host', hostId: '11111111-1111-4111-8111-111111111111' },
at: 1_700_000_000,
reason: 'revoked',
}
// Act
await bus.publish(signal)
// Assert — bus publishes exactly one message on the shared channel, JSON-encoded.
expect(calls).toHaveLength(1)
const [channel, message] = calls[0]!
expect(channel).toBe(RELAY_REVOCATIONS_CHANNEL)
expect(JSON.parse(message)).toMatchObject({ scope: { kind: 'host', hostId: signal.scope.kind === 'host' ? signal.scope.hostId : '' } })
})
})

View File

@@ -35,7 +35,7 @@ describe('T8 signHostLeaf — INV14 registry-gated + CSR PoP', () => {
const forged = new Uint8Array(96)
forged.set(publicKeyRaw, 0)
forged.set(randomBytes(64), 32)
expect(verifyCsrPoP(forged).ok).toBe(false)
expect((await verifyCsrPoP(forged)).ok).toBe(false)
await expect(signer.signHostLeaf(host.hostId, publicKeyRaw, forged)).rejects.toBeInstanceOf(LeafSignError)
expect(signSpy).not.toHaveBeenCalled()
})

View File

@@ -0,0 +1,220 @@
/**
* OBJECTIVE ORACLE (INV4/INV14) — proves the control-plane's REAL leaf issuer emits an X.509 leaf
* that relay-auth's `verifyAgentCert` accepts, WITHOUT deploying either service.
*
* A throwaway Ed25519 root→intermediate chain is generated in-test; a leaf is issued through
* `createRealLeafSigner` for a random account/host + agent pubkey; then relay-auth verifies the
* leaf against the intermediate+root bundle with a fake host registry. Positive path asserts
* `{ ok: true, hostId, accountId }`; negatives assert the exact reject reasons.
*/
import 'reflect-metadata'
import { describe, test, expect } from 'vitest'
import * as x509 from '@peculiar/x509'
import { webcrypto, randomUUID } from 'node:crypto'
import { verifyAgentCert, defaultParseX509 } from 'relay-auth/src/agent/verify-mtls.js'
import { spiffeIdFor } from 'relay-auth/src/agent/spiffe.js'
import type { HostRecord } from 'relay-contracts'
import { createMemoryStores } from '../src/store/memory.js'
import { createHostRegistry } from '../src/registry/hosts.js'
import { createRealLeafSigner, loadRealLeafSigner } from '../src/ca/issue.js'
import { buildCsr } from '../src/ca/csr.js'
import { fingerprint } from '../src/ca/fingerprint.js'
import { generateEd25519 } from '../src/util/crypto.js'
x509.cryptoProvider.set(webcrypto)
const TRUST_DOMAIN = 'example.com'
const DAY_MS = 24 * 60 * 60 * 1000
interface Ca {
readonly intKey: CryptoKey
readonly issuerName: x509.Name
readonly caChainDer: readonly Uint8Array[]
readonly caChainPem: string
readonly intermediateKeyPem: string
readonly intermediateCertPem: string
readonly rootCertPem: string
}
type KeyPair = { readonly publicKey: CryptoKey; readonly privateKey: CryptoKey }
function derToPemLabeled(der: Uint8Array, label: string): string {
const b64 = Buffer.from(der).toString('base64').match(/.{1,64}/g)?.join('\n') ?? ''
return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----\n`
}
async function makeCa(): Promise<Ca> {
const subtle = webcrypto.subtle
const rootKeys = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as KeyPair
const intKeys = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as KeyPair
const now = Date.now()
const rootCert = await x509.X509CertificateGenerator.createSelfSigned({
serialNumber: '01',
name: 'CN=relay-root',
notBefore: new Date(now - DAY_MS),
notAfter: new Date(now + DAY_MS),
keys: rootKeys,
signingAlgorithm: { name: 'Ed25519' },
extensions: [new x509.BasicConstraintsExtension(true, undefined, true)],
})
const intCert = await x509.X509CertificateGenerator.create({
serialNumber: '02',
subject: 'CN=relay-intermediate',
issuer: rootCert.subjectName,
notBefore: new Date(now - DAY_MS),
notAfter: new Date(now + DAY_MS),
publicKey: intKeys.publicKey,
signingKey: rootKeys.privateKey,
signingAlgorithm: { name: 'Ed25519' },
extensions: [new x509.BasicConstraintsExtension(true, undefined, true)],
})
const intKeyPkcs8 = new Uint8Array(await subtle.exportKey('pkcs8', intKeys.privateKey))
return {
intKey: intKeys.privateKey,
issuerName: intCert.subjectName,
caChainDer: [new Uint8Array(intCert.rawData), new Uint8Array(rootCert.rawData)],
caChainPem: `${intCert.toString('pem')}\n${rootCert.toString('pem')}`,
intermediateKeyPem: derToPemLabeled(intKeyPkcs8, 'PRIVATE KEY'),
intermediateCertPem: intCert.toString('pem'),
rootCertPem: rootCert.toString('pem'),
}
}
function derToPem(der: Uint8Array): string {
return new x509.X509Certificate(der).toString('pem')
}
/** Minimal relay-contracts HostRecord for the fake registry (only accountId/status are read). */
function fakeHost(hostId: string, accountId: string, status: HostRecord['status']): HostRecord {
return {
hostId,
accountId,
subdomain: 'host',
agentPubkey: new Uint8Array(32),
enrollFpr: 'fpr',
status,
lastSeen: new Date().toISOString(),
createdAt: new Date().toISOString(),
revokedAt: status === 'revoked' ? new Date().toISOString() : null,
} as HostRecord
}
async function issueLeaf() {
const ca = await makeCa()
const stores = createMemoryStores()
const hosts = createHostRegistry({ hosts: stores.hosts })
const accountId = randomUUID()
const { publicKeyRaw, privateKey } = generateEd25519()
const host = await hosts.bindHost({
accountId,
subdomain: 'alice',
agentPubkey: publicKeyRaw,
enrollFpr: fingerprint(publicKeyRaw),
})
const signer = createRealLeafSigner({
hosts: stores.hosts,
intermediateKey: ca.intKey,
issuerName: ca.issuerName,
caChainDer: ca.caChainDer,
trustDomain: TRUST_DOMAIN,
})
const { cert, caChain } = await signer.signHostLeaf(host.hostId, publicKeyRaw, buildCsr(privateKey, publicKeyRaw))
return { ca, accountId, hostId: host.hostId, publicKeyRaw, leafPem: derToPem(cert), caChain }
}
describe('interop: control-plane real leaf ⇄ relay-auth verifyAgentCert', () => {
test('valid leaf for an active enrolled host → { ok: true, hostId, accountId }', async () => {
const { ca, accountId, hostId, leafPem, caChain } = await issueLeaf()
expect(caChain).toHaveLength(2) // intermediate + root DER
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'online') : null) }
const res = await verifyAgentCert(leafPem, ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
expect(res).toEqual({ ok: true, hostId, accountId })
})
test('SPIFFE accountId ≠ registry accountId → spiffe_account_mismatch', async () => {
const { ca, hostId, leafPem } = await issueLeaf()
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, randomUUID(), 'online') : null) }
const res = await verifyAgentCert(leafPem, ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
expect(res.ok).toBe(false)
expect(res.reason).toBe('spiffe_account_mismatch')
})
test('revoked host → host_revoked', async () => {
const { ca, accountId, hostId, leafPem } = await issueLeaf()
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'revoked') : null) }
const res = await verifyAgentCert(leafPem, ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
expect(res.ok).toBe(false)
expect(res.reason).toBe('host_revoked')
})
test('expired leaf → expired', async () => {
const ca = await makeCa()
const accountId = randomUUID()
const hostId = randomUUID()
const { publicKeyRaw } = generateEd25519()
const spiffe = spiffeIdFor(accountId, hostId, TRUST_DOMAIN)
const prefix = Uint8Array.from([0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00])
const spki = new Uint8Array(44)
spki.set(prefix, 0)
spki.set(publicKeyRaw, 12)
const subjectKey = await webcrypto.subtle.importKey('spki', spki, { name: 'Ed25519' }, true, ['verify'])
const now = Date.now()
const expiredLeaf = await x509.X509CertificateGenerator.create({
serialNumber: '0e',
subject: `CN=${hostId}`,
issuer: ca.issuerName,
notBefore: new Date(now - 2 * DAY_MS),
notAfter: new Date(now - DAY_MS), // already expired
publicKey: subjectKey,
signingKey: ca.intKey,
signingAlgorithm: { name: 'Ed25519' },
extensions: [new x509.SubjectAlternativeNameExtension([{ type: 'url', value: spiffe }])],
})
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'online') : null) }
const res = await verifyAgentCert(
expiredLeaf.toString('pem'),
ca.caChainPem,
Math.floor(Date.now() / 1000),
registry,
defaultParseX509,
)
expect(res.ok).toBe(false)
expect(res.reason).toBe('expired')
})
test('boot path: loadRealLeafSigner (PEM material) issues a verifiable leaf', async () => {
const ca = await makeCa()
const stores = createMemoryStores()
const hosts = createHostRegistry({ hosts: stores.hosts })
const accountId = randomUUID()
const { publicKeyRaw, privateKey } = generateEd25519()
const host = await hosts.bindHost({
accountId,
subdomain: 'boot',
agentPubkey: publicKeyRaw,
enrollFpr: fingerprint(publicKeyRaw),
})
const signer = await loadRealLeafSigner({
hosts: stores.hosts,
intermediateKeyPem: ca.intermediateKeyPem,
intermediateCertPem: ca.intermediateCertPem,
rootCertPem: ca.rootCertPem,
trustDomain: TRUST_DOMAIN,
})
const { cert } = await signer.signHostLeaf(host.hostId, publicKeyRaw, buildCsr(privateKey, publicKeyRaw))
const registry = {
getById: async (id: string) => (id === host.hostId ? fakeHost(host.hostId, accountId, 'online') : null),
}
const res = await verifyAgentCert(derToPem(cert), ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
expect(res).toEqual({ ok: true, hostId: host.hostId, accountId })
})
test('intermediate-only bundle (no root) → chain_invalid', async () => {
const { ca, accountId, hostId, leafPem } = await issueLeaf()
const intermediateOnly = ca.caChainPem.split('-----END CERTIFICATE-----')[0]! + '-----END CERTIFICATE-----\n'
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'online') : null) }
const res = await verifyAgentCert(leafPem, intermediateOnly, Math.floor(Date.now() / 1000), registry, defaultParseX509)
expect(res.ok).toBe(false)
expect(res.reason).toBe('chain_invalid')
})
})

View File

@@ -0,0 +1,390 @@
/**
* A1 — integration tests for the Postgres store adapter (src/store/pg.ts) + migration runner
* (src/db/migrate.ts). Exercises EVERY port method and the tricky semantics that must match
* store/memory.ts: INV8 version history, casRedeem single-winner, registerFailure, subdomain
* reserve/isTaken, route TTL fail-closed + listByNode, append-only metering/audit time windows.
*
* Gated on PG_TEST_URL. If unset, a disposable postgres:16 container is started on port 5433.
*/
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import { execSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { setTimeout as sleep } from 'node:timers/promises'
import { createPgPool, createQuery, type QueryFn } from '../../src/db/pool.js'
import { runMigrations } from '../../src/db/migrate.js'
import { createPgStores } from '../../src/store/pg.js'
import type { Stores } from '../../src/store/ports.js'
import type { AccountRecord, HostRecord } from '../../src/model/records.js'
const CONTAINER = 'cp_pg_a1_test'
const PORT = 5433
const READY_TIMEOUT_MS = 60_000
let pool: ReturnType<typeof createPgPool> | undefined
let query: QueryFn
let stores: Stores
let startedContainer = false
beforeAll(async () => {
let url = process.env.PG_TEST_URL
if (!url) {
try {
execSync(`docker rm -f ${CONTAINER}`, { stdio: 'ignore' })
} catch {
/* nothing to remove */
}
execSync(
`docker run -d --rm --name ${CONTAINER} -e POSTGRES_PASSWORD=test -e POSTGRES_DB=cp -p ${PORT}:5432 postgres:16`,
{ stdio: 'ignore' },
)
startedContainer = true
url = `postgres://postgres:test@127.0.0.1:${PORT}/cp`
}
pool = createPgPool(url)
query = createQuery(pool)
// Poll until the server accepts queries.
const deadline = Date.now() + READY_TIMEOUT_MS
for (;;) {
try {
await query('SELECT 1', [])
break
} catch (err) {
if (Date.now() > deadline) throw err
await sleep(500)
}
}
await runMigrations(query)
// Idempotency: a second run must not throw (all migrations are IF NOT EXISTS).
await runMigrations(query)
stores = createPgStores(query)
}, 180_000)
afterAll(async () => {
await pool?.end()
if (startedContainer) {
try {
execSync(`docker rm -f ${CONTAINER}`, { stdio: 'ignore' })
} catch {
/* best effort */
}
}
})
// ---- fixtures ---------------------------------------------------------------------------------
function newAccount(): AccountRecord {
return { accountId: randomUUID(), plan: 'free', createdAt: new Date().toISOString(), status: 'active' }
}
async function makeAccount(): Promise<AccountRecord> {
const rec = newAccount()
await stores.accounts.insert(rec)
return rec
}
async function makeHost(accountId: string, subdomain = `sub-${randomUUID().slice(0, 8)}`): Promise<HostRecord> {
const rec: HostRecord = {
hostId: randomUUID(),
accountId,
subdomain,
agentPubkey: new Uint8Array([1, 2, 3, 4]),
enrollFpr: `fpr-${subdomain}`,
status: 'offline',
lastSeen: new Date().toISOString(),
createdAt: new Date().toISOString(),
revokedAt: null,
}
await stores.hosts.insert(rec)
return rec
}
// ---- AccountStore -----------------------------------------------------------------------------
describe('AccountStore', () => {
test('insert + get round-trips the record', async () => {
const rec = await makeAccount()
expect(await stores.accounts.get(rec.accountId)).toEqual(rec)
})
test('get returns null for unknown account', async () => {
expect(await stores.accounts.get(randomUUID())).toBeNull()
})
test('duplicate insert throws', async () => {
const rec = await makeAccount()
await expect(stores.accounts.insert(rec)).rejects.toThrow('duplicate accountId')
})
test('swapStatus bumps status + appends INV8 version history atomically', async () => {
const rec = await makeAccount()
expect(await stores.accounts.versions(rec.accountId)).toEqual([
{ accountId: rec.accountId, version: 1, status: 'active', changedAt: rec.createdAt, changedBy: 'system' },
])
const swapped = await stores.accounts.swapStatus(rec.accountId, 'suspended', 'admin-1')
expect(swapped.status).toBe('suspended')
expect(swapped.accountId).toBe(rec.accountId)
expect((await stores.accounts.get(rec.accountId))?.status).toBe('suspended')
const versions = await stores.accounts.versions(rec.accountId)
expect(versions.length).toBe(2)
expect(versions[1]).toMatchObject({ version: 2, status: 'suspended', changedBy: 'admin-1' })
})
test('swapStatus on unknown account throws', async () => {
await expect(stores.accounts.swapStatus(randomUUID(), 'suspended', 'x')).rejects.toThrow('account not found')
})
})
// ---- HostStore --------------------------------------------------------------------------------
describe('HostStore', () => {
test('insert + get + getBySubdomain + listByAccount', async () => {
const acct = await makeAccount()
const host = await makeHost(acct.accountId)
expect(await stores.hosts.get(host.hostId)).toEqual(host)
expect(await stores.hosts.getBySubdomain(host.subdomain)).toEqual(host)
expect(await stores.hosts.listByAccount(acct.accountId)).toEqual([host])
})
test('get / getBySubdomain return null when absent', async () => {
expect(await stores.hosts.get(randomUUID())).toBeNull()
expect(await stores.hosts.getBySubdomain(`missing-${randomUUID()}`)).toBeNull()
})
test('duplicate hostId and duplicate subdomain throw distinctly', async () => {
const acct = await makeAccount()
const host = await makeHost(acct.accountId)
await expect(stores.hosts.insert(host)).rejects.toThrow('duplicate hostId')
const clash: HostRecord = { ...host, hostId: randomUUID() }
await expect(stores.hosts.insert(clash)).rejects.toThrow('duplicate subdomain')
})
test('swapStatus to revoked sets revokedAt + version history', async () => {
const acct = await makeAccount()
const host = await makeHost(acct.accountId)
const revokedAt = new Date().toISOString()
const swapped = await stores.hosts.swapStatus(host.hostId, 'revoked', revokedAt, 'admin-2')
expect(swapped.status).toBe('revoked')
expect(swapped.revokedAt).toBe(revokedAt)
const versions = await stores.hosts.versions(host.hostId)
expect(versions.length).toBe(2)
expect(versions[0]).toMatchObject({ version: 1, status: 'offline', revokedAt: null, changedBy: 'system' })
expect(versions[1]).toMatchObject({ version: 2, status: 'revoked', revokedAt, changedBy: 'admin-2' })
})
test('swapStatus on unknown host throws', async () => {
await expect(stores.hosts.swapStatus(randomUUID(), 'online', null, 'x')).rejects.toThrow('host not found')
})
test('touchLastSeen updates the advisory cache (no version row)', async () => {
const acct = await makeAccount()
const host = await makeHost(acct.accountId)
const ts = new Date(Date.now() + 5000).toISOString()
await stores.hosts.touchLastSeen(host.hostId, ts)
expect((await stores.hosts.get(host.hostId))?.lastSeen).toBe(ts)
expect((await stores.hosts.versions(host.hostId)).length).toBe(1) // unchanged
await stores.hosts.touchLastSeen(randomUUID(), ts) // no-op on absent host, no throw
})
})
// ---- SessionStore -----------------------------------------------------------------------------
describe('SessionStore', () => {
test('insert + get + duplicate + missing', async () => {
const acct = await makeAccount()
const host = await makeHost(acct.accountId)
const rec = {
sessionId: randomUUID(),
hostId: host.hostId,
accountId: acct.accountId,
createdAt: new Date().toISOString(),
lastAttachAt: new Date().toISOString(),
}
await stores.sessions.insert(rec)
expect(await stores.sessions.get(rec.sessionId)).toEqual(rec)
await expect(stores.sessions.insert(rec)).rejects.toThrow('duplicate sessionId')
expect(await stores.sessions.get(randomUUID())).toBeNull()
})
})
// ---- SubdomainStore ---------------------------------------------------------------------------
describe('SubdomainStore', () => {
test('reserve/isTaken reflect host ownership of the label', async () => {
const sub = `claim-${randomUUID().slice(0, 8)}`
expect(await stores.subdomains.isTaken(sub)).toBe(false)
expect(await stores.subdomains.reserve(sub)).toBe(true) // free (no host owns it yet)
const acct = await makeAccount()
await makeHost(acct.accountId, sub)
expect(await stores.subdomains.isTaken(sub)).toBe(true)
expect(await stores.subdomains.reserve(sub)).toBe(false) // a host now owns it
})
})
// ---- PairingStore -----------------------------------------------------------------------------
describe('PairingStore', () => {
test('insert + get + casRedeem single-winner + registerFailure', async () => {
const acct = await makeAccount()
const codeHash = `code-${randomUUID()}`
const rec = {
codeHash,
accountId: acct.accountId,
expiresAt: new Date(Date.now() + 60_000).toISOString(),
redeemedAt: null,
}
await stores.pairing.insert(rec)
await expect(stores.pairing.insert(rec)).rejects.toThrow('duplicate code_hash')
const got = await stores.pairing.get(codeHash)
expect(got).toEqual({ record: rec, redeemAttempts: 0 })
const now = new Date().toISOString()
expect(await stores.pairing.casRedeem(codeHash, now)).toBe('ok')
expect(await stores.pairing.casRedeem(codeHash, now)).toBe('already_redeemed')
expect(await stores.pairing.casRedeem(`unknown-${randomUUID()}`, now)).toBe('unknown')
expect((await stores.pairing.get(codeHash))?.record.redeemedAt).toBe(now)
})
test('registerFailure increments; unknown code returns 0', async () => {
const acct = await makeAccount()
const codeHash = `code-${randomUUID()}`
await stores.pairing.insert({
codeHash,
accountId: acct.accountId,
expiresAt: new Date(Date.now() + 60_000).toISOString(),
redeemedAt: null,
})
expect(await stores.pairing.registerFailure(codeHash)).toBe(1)
expect(await stores.pairing.registerFailure(codeHash)).toBe(2)
expect((await stores.pairing.get(codeHash))?.redeemAttempts).toBe(2)
expect(await stores.pairing.registerFailure(`unknown-${randomUUID()}`)).toBe(0)
})
test('get returns null for unknown code', async () => {
expect(await stores.pairing.get(`nope-${randomUUID()}`)).toBeNull()
})
})
// ---- RouteStore -------------------------------------------------------------------------------
describe('RouteStore', () => {
test('set → get → refreshTtl → delete', async () => {
const hostId = randomUUID()
const entry = { relayNodeId: 'node-A', updatedAt: new Date().toISOString() }
await stores.routes.set(hostId, entry, 60)
expect(await stores.routes.get(hostId)).toEqual(entry)
expect(await stores.routes.refreshTtl(hostId, 60)).toBe(true)
await stores.routes.delete(hostId)
expect(await stores.routes.get(hostId)).toBeNull()
})
test('expired route fails closed (get null, refreshTtl false)', async () => {
const hostId = randomUUID()
const entry = { relayNodeId: 'node-A', updatedAt: new Date().toISOString() }
await stores.routes.set(hostId, entry, -1) // already expired
expect(await stores.routes.get(hostId)).toBeNull()
expect(await stores.routes.refreshTtl(hostId, 60)).toBe(false)
expect(await stores.routes.refreshTtl(randomUUID(), 60)).toBe(false) // absent
})
test('listByNode returns only live routes targeting the node', async () => {
const node = `node-${randomUUID().slice(0, 8)}`
const live1 = randomUUID()
const live2 = randomUUID()
const expired = randomUUID()
const otherNode = randomUUID()
await stores.routes.set(live1, { relayNodeId: node, updatedAt: new Date().toISOString() }, 60)
await stores.routes.set(live2, { relayNodeId: node, updatedAt: new Date().toISOString() }, 60)
await stores.routes.set(expired, { relayNodeId: node, updatedAt: new Date().toISOString() }, -1)
await stores.routes.set(otherNode, { relayNodeId: 'someone-else', updatedAt: new Date().toISOString() }, 60)
const listed = await stores.routes.listByNode(node)
const hostIds = listed.map((r) => r.hostId).sort()
expect(hostIds).toEqual([live1, live2].sort())
expect(listed.every((r) => r.entry.relayNodeId === node)).toBe(true)
})
})
// ---- NodeStore --------------------------------------------------------------------------------
describe('NodeStore', () => {
test('upsert + get + setStatus + touch + delete', async () => {
const nodeId = `relay-${randomUUID().slice(0, 8)}`
const row = { nodeId, addr: '10.0.0.1:9000', status: 'active' as const, lastSeen: new Date().toISOString() }
await stores.nodes.upsert(row)
expect(await stores.nodes.get(nodeId)).toEqual(row)
await stores.nodes.upsert({ ...row, addr: '10.0.0.2:9000' }) // upsert overwrites
expect((await stores.nodes.get(nodeId))?.addr).toBe('10.0.0.2:9000')
await stores.nodes.setStatus(nodeId, 'draining')
expect((await stores.nodes.get(nodeId))?.status).toBe('draining')
const later = new Date(Date.now() + 1000).toISOString()
await stores.nodes.touch(nodeId, later)
expect((await stores.nodes.get(nodeId))?.lastSeen).toBe(later)
await stores.nodes.delete(nodeId)
expect(await stores.nodes.get(nodeId)).toBeNull()
})
test('setStatus on unknown node throws; touch is a silent no-op', async () => {
await expect(stores.nodes.setStatus(`ghost-${randomUUID()}`, 'draining')).rejects.toThrow('node not found')
await stores.nodes.touch(`ghost-${randomUUID()}`, new Date().toISOString()) // no throw
})
})
// ---- MeteringStore ----------------------------------------------------------------------------
describe('MeteringStore', () => {
test('append-only + inclusive [from,to] time-window query filtered by account', async () => {
const acct = await makeAccount()
const other = await makeAccount()
const host = await makeHost(acct.accountId)
const otherHost = await makeHost(other.accountId)
const t0 = '2026-01-01T00:00:00.000Z'
const t1 = '2026-01-01T01:00:00.000Z'
const t2 = '2026-01-01T02:00:00.000Z'
await stores.metering.append({ hostId: host.hostId, accountId: acct.accountId, nodeId: 'n1', concurrentViewers: 3, sampledAt: t0 })
await stores.metering.append({ hostId: host.hostId, accountId: acct.accountId, nodeId: 'n1', concurrentViewers: 5, sampledAt: t1 })
await stores.metering.append({ hostId: host.hostId, accountId: acct.accountId, nodeId: 'n1', concurrentViewers: 9, sampledAt: t2 })
await stores.metering.append({ hostId: otherHost.hostId, accountId: other.accountId, nodeId: 'n1', concurrentViewers: 1, sampledAt: t1 })
const inWindow = await stores.metering.query(acct.accountId, t0, t1)
expect(inWindow.map((r) => r.concurrentViewers)).toEqual([3, 5]) // t2 excluded, other acct excluded
expect(inWindow[0]).toMatchObject({ hostId: host.hostId, accountId: acct.accountId, sampledAt: t0 })
const all = await stores.metering.query(acct.accountId, t0, t2)
expect(all.length).toBe(3)
})
})
// ---- AuditStore -------------------------------------------------------------------------------
describe('AuditStore', () => {
test('append-only + time-window query + null hostId + meta jsonb round-trip', async () => {
const accountId = randomUUID()
const t0 = '2026-02-01T00:00:00.000Z'
const t1 = '2026-02-01T01:00:00.000Z'
const t2 = '2026-02-01T02:00:00.000Z'
await stores.audit.append({ action: 'provision', principalId: 'p1', accountId, hostId: 'h1', ts: t0, meta: { ip: '1.2.3.4' } })
await stores.audit.append({ action: 'revoke', principalId: 'p2', accountId, hostId: null, ts: t1, meta: {} })
await stores.audit.append({ action: 'late', principalId: 'p3', accountId, hostId: null, ts: t2, meta: {} })
await stores.audit.append({ action: 'other', principalId: 'p9', accountId: randomUUID(), hostId: null, ts: t1, meta: {} })
const rows = await stores.audit.query(accountId, t0, t1)
expect(rows.map((r) => r.action)).toEqual(['provision', 'revoke']) // t2 + other account excluded
expect(rows[0]).toEqual({ action: 'provision', principalId: 'p1', accountId, hostId: 'h1', ts: t0, meta: { ip: '1.2.3.4' } })
expect(rows[1]?.hostId).toBeNull()
})
})

View File

@@ -0,0 +1,78 @@
/**
* A3 — REAL capability verifier (boot/verifier.ts) backed by relay-auth's async `verifyCapabilityToken`.
* Mints tokens with the paired Ed25519 private key via P5's `issueCapabilityToken`, configures the
* matching public key, and asserts: a good token verifies; wrong `aud`, past `exp`, and a token
* signed by a NON-matching key all reject (deny-by-default, INV6).
*/
import { describe, test, expect, beforeAll } from 'vitest'
import { issueCapabilityToken } from 'relay-auth'
import type { AuthenticatedPrincipal } from 'relay-auth'
import { encodeBase64UrlBytes } from 'relay-contracts'
import { createCapabilityVerifier, configureCapabilityVerifyKey } from '../src/boot/verifier.js'
import type { CapabilityVerifier } from '../src/api/authz.js'
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
const AUD = 'term.example.com'
const NOW = 1_000_000
// A base64url SHA-256 JWK thumbprint is exactly 43 chars of [A-Za-z0-9_-]; 32 bytes → 43 base64url chars.
const CNF_JKT = encodeBase64UrlBytes(new Uint8Array(32).fill(7))
const principal: AuthenticatedPrincipal = {
kind: 'human',
accountId: ACCOUNT_A,
principalId: 'cred-1',
amr: ['passkey'],
authAt: NOW - 10,
stepUpAt: null,
}
type KeyPair = { publicKey: CryptoKey; privateKey: CryptoKey }
async function genKeyPair(): Promise<KeyPair> {
// generateKey's named-algorithm overload is typed as CryptoKey; Ed25519 yields a pair at runtime.
return (await globalThis.crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as KeyPair
}
function mint(signingKey: CryptoKey, now: number): Promise<string> {
return issueCapabilityToken(
{ principal, aud: AUD, host: 'host-1', rights: ['manage'], ttlSeconds: 60, cnfJkt: CNF_JKT },
signingKey,
now,
)
}
describe('A3 real capability verifier (relay-auth verifyCapabilityToken)', () => {
let signingKey: CryptoKey
let verifier: CapabilityVerifier
beforeAll(async () => {
const pair = await genKeyPair()
signingKey = pair.privateKey
const rawPub = new Uint8Array(await globalThis.crypto.subtle.exportKey('raw', pair.publicKey))
await configureCapabilityVerifyKey(rawPub)
verifier = createCapabilityVerifier()
})
test('verifies a token signed by the configured key and returns the account principal', async () => {
const raw = await mint(signingKey, NOW)
const token = await verifier.verify(raw, AUD, NOW)
expect(token.sub).toBe(ACCOUNT_A)
expect(token.aud).toBe(AUD)
expect(token.rights).toContain('manage')
})
test('rejects a token minted for a different aud (Host-confusion guard)', async () => {
const raw = await mint(signingKey, NOW)
await expect(verifier.verify(raw, 'evil.example.com', NOW)).rejects.toThrow()
})
test('rejects an expired token', async () => {
const raw = await mint(signingKey, NOW) // exp = NOW + 60
await expect(verifier.verify(raw, AUD, NOW + 120)).rejects.toThrow()
})
test('rejects a token signed by a NON-matching key (bad signature)', async () => {
const other = await genKeyPair()
const raw = await mint(other.privateKey, NOW)
await expect(verifier.verify(raw, AUD, NOW)).rejects.toThrow()
})
})

43
deploy/.env.example Normal file
View File

@@ -0,0 +1,43 @@
# RELAY-PHASE1 env template. Copy to deploy/.env (gitignored) and fill. NEVER commit real secrets.
# Convert relative → absolute paths on the VPS. See docs/PLAN_RELAY_PHASE1.md §3.
# ── Docker Compose (deploy/docker-compose.yml) ────────────────────────────────────────────────
POSTGRES_DB=relay
POSTGRES_USER=relay
POSTGRES_PASSWORD= # REQUIRED — strong random
# ── Shared datastores (loopback; both processes) ─────────────────────────────────────────────
PG_URL=postgres://relay:CHANGEME@127.0.0.1:5432/relay
REDIS_URL=redis://127.0.0.1:6379
# ── Deployment identity ──────────────────────────────────────────────────────────────────────
BASE_DOMAIN=term.example.com # your ICP-filed domain; browser hits <sub>.<BASE_DOMAIN>
RELAY_NODE_ID=relay-1
RELAY_TRUST_DOMAIN=relay.example.com # SPIFFE trust domain for agent certs
# ── P5 capability keypair — CONTROL-PLANE SIGNS, RELAY VERIFIES. Same public key on both. ────
# Generate an Ed25519 keypair; keep the PRIVATE key only where tokens are minted (never on disk here).
CAPABILITY_SIGN_PUBKEY_B64= # control-plane env: raw 32-byte Ed25519 pubkey, base64
RELAY_AUTH_VERIFY_PUBKEY= # relay env: SAME key, base64url
# ── Control-plane (P3) ───────────────────────────────────────────────────────────────────────
CP_BIND_HOST=127.0.0.1 # admin API stays loopback-only (front only via the relay)
CP_BIND_PORT=8080
CA_INTERMEDIATE_KMS_KEY_REF=dev-local # Phase 1: dev in-process signer accepts any ref (KMS → Phase 2)
CA_INTERMEDIATE_CERT_PATH=/etc/relay/ca/intermediate.cert.pem
NODE_MTLS_TRUST_BUNDLE_PATH=/etc/relay/ca/agent-ca.bundle.pem
# HEARTBEAT_TTL_SEC=15 PAIRING_TTL_SEC=600 PAIRING_MAX_REDEEM_ATTEMPTS=5 (defaults)
# ── Relay data-plane (P1, relay-run Phase 1) ─────────────────────────────────────────────────
BIND_HOST=0.0.0.0
BIND_PORT=443 # browser WSS (open in the Aliyun security group)
TLS_CERT_PATH=/etc/relay/tls/fullchain.pem # Let's Encrypt for <sub>.<BASE_DOMAIN>
TLS_KEY_PATH=/etc/relay/tls/privkey.pem
AGENT_BIND_PORT=8444 # agent mTLS (open in the security group)
AGENT_CA_CERT_PATH=/etc/relay/ca/agent-ca.cert.pem # private enrollment CA — NOT Let's Encrypt
AGENT_CA_CHAIN_PATH=/etc/relay/ca/agent-ca.bundle.pem
# ── Agent (P2) — runs on YOUR laptop, not the VPS. Shown here for reference. ──────────────────
# RELAY_URL=wss://<sub>.term.example.com:8444
# ENROLL_URL=https://<sub>.term.example.com/enroll (or the CP host)
# HOST_ID=... SUBDOMAIN=<sub> LOCAL_TARGET_URL=ws://127.0.0.1:3000 STATE_DIR=~/.web-terminal-agent

277
deploy/RUNBOOK.md Normal file
View File

@@ -0,0 +1,277 @@
# RUNBOOK — deploy the rendezvous-relay to VPS `8.138.1.192` (Alibaba Cloud, mainland)
> **Staging, durable single-tenant** (PLAN_RELAY_PHASE1 §0). Real Postgres + Redis (Docker Compose on
> the VPS), real Let's Encrypt TLS on `:443` against an **already ICP-filed** domain, agent dials OUT
> from the operator's laptop. Dev in-process CA signer is accepted for staging (KMS → Phase 2).
>
> Everything below is a **staging template** — replace every `<placeholder>` and confirm each path on
> the box. Config is **env-only**; no host/port/secret is hardcoded in code or units.
Two independent trust chains — never cross-wire them:
| Chain | Issued by | Protects | Script |
|---|---|---|---|
| **Public web PKI** | Let's Encrypt | browser `:443` WSS (`TLS_CERT_PATH`) | `issue-tls-cert.sh` |
| **Private enrollment CA** | your offline root | agent mTLS + relay agent-server cert (`AGENT_CA_*`) | `gen-agent-ca.sh` |
And one shared key: the **P5 capability keypair** — control-plane **signs**, relay + admin API
**verify** (`gen-capability-key.sh`). `CAPABILITY_SIGN_PUBKEY_B64` (CP) and `RELAY_AUTH_VERIFY_PUBKEY`
(relay) are the **same public key**, two encodings.
---
## 0. Prerequisites (on `8.138.1.192`)
```bash
# Docker Engine + Compose plugin
curl -fsSL https://get.docker.com | sh
sudo systemctl enable --now docker
# Node 20 (satisfies control-plane>=18, relay-run>=20, agent>=18) via NodeSource
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash -
sudo apt-get install -y nodejs git openssl
# Clone (this runbook assumes /opt/web-terminal; adjust the systemd --prefix if you change it)
sudo git clone <REPO_URL> /opt/web-terminal
cd /opt/web-terminal
npm ci # root deps
npm --prefix control-plane ci
npm --prefix relay-run ci
npm --prefix relay-web ci
# Dedicated non-root service user + secret dir
sudo useradd --system --home /opt/web-terminal relay || true
sudo mkdir -p /etc/relay
```
**DNS (do this first — LE HTTP-01 and the browser both need it):** create an A-record
`<SUBDOMAIN>.<BASE_DOMAIN>``8.138.1.192`. The domain must be **ICP-filed** (mainland Aliyun blocks
`:80/:443` on unfiled domains; if unfiled, you must use DNS-01 in step 2).
---
## 1. Bring up Postgres + Redis (Docker Compose, loopback-only)
```bash
cd /opt/web-terminal
cp deploy/.env.example deploy/.env # gitignored; fill POSTGRES_PASSWORD (strong random)
docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d
docker compose -f deploy/docker-compose.yml ps # both healthy; bound to 127.0.0.1 only
```
Both services publish to `127.0.0.1` only — never the public interface (they are the relay's private
state).
---
## 2. Generate the CA, the capability key, and the TLS cert
```bash
# (a) PRIVATE enrollment CA (agent mTLS) — NOT Let's Encrypt.
sudo AGENT_FACING_HOST="<SUBDOMAIN>.<BASE_DOMAIN>" \
AGENT_FACING_IP="8.138.1.192" \
RELAY_TRUST_DOMAIN="<RELAY_TRUST_DOMAIN>" \
bash deploy/scripts/gen-agent-ca.sh
# writes /etc/relay/ca/{root,intermediate,agent-ca.*,relay-agent-server.*}
# (b) P5 capability keypair — prints BOTH public-key encodings; private key -> 0600 file.
sudo bash deploy/scripts/gen-capability-key.sh
# -> note CAPABILITY_SIGN_PUBKEY_B64 (base64) and RELAY_AUTH_VERIFY_PUBKEY (base64url); same key.
# (c) PUBLIC TLS cert for the browser :443 (Let's Encrypt).
# http-01 needs inbound :80 open DURING issuance (step 4); dns-01 needs no inbound port.
sudo BASE_DOMAIN="<BASE_DOMAIN>" SUBDOMAIN="<SUBDOMAIN>" ACME_EMAIL="<you@example.com>" \
ACME_METHOD="http-01" \
TLS_CERT_PATH="/etc/relay/tls/fullchain.pem" TLS_KEY_PATH="/etc/relay/tls/privkey.pem" \
bash deploy/scripts/issue-tls-cert.sh
sudo chown -R relay:relay /etc/relay
```
> **Staging CA seam (A-wave, not this task):** the control-plane's dev in-process signer must sign
> agent leaves with `/etc/relay/ca/intermediate.key.pem` so they chain to `agent-ca.bundle.pem`.
> `CA_INTERMEDIATE_KMS_KEY_REF=dev-local` selects the dev signer; point it at that key. Phase 2 = KMS.
> After first boot, move `/etc/relay/ca/root.key.pem` **off** the host (offline anchor).
---
## 3. Fill the two env files
Copy the relevant blocks of `deploy/.env.example` into two 0600 files. **The linchpin:**
`CAPABILITY_SIGN_PUBKEY_B64` (control-plane) and `RELAY_AUTH_VERIFY_PUBKEY` (relay) are the SAME key
from step 2b — paste both.
```bash
sudo install -m 600 -o relay -g relay /dev/null /etc/relay/control-plane.env
sudo install -m 600 -o relay -g relay /dev/null /etc/relay/relay.env
```
`/etc/relay/control-plane.env` (P3):
```ini
PG_URL=postgres://relay:<POSTGRES_PASSWORD>@127.0.0.1:5432/relay
REDIS_URL=redis://127.0.0.1:6379
CAPABILITY_SIGN_PUBKEY_B64=<base64 from step 2b>
CA_INTERMEDIATE_KMS_KEY_REF=dev-local
CA_INTERMEDIATE_CERT_PATH=/etc/relay/ca/intermediate.cert.pem
NODE_MTLS_TRUST_BUNDLE_PATH=/etc/relay/ca/agent-ca.bundle.pem
BASE_DOMAIN=<BASE_DOMAIN>
CP_BIND_HOST=127.0.0.1
CP_BIND_PORT=8080
# HEARTBEAT_TTL_SEC / PAIRING_TTL_SEC / PAIRING_MAX_REDEEM_ATTEMPTS use defaults if unset
```
`/etc/relay/relay.env` (P1 data-plane):
```ini
BASE_DOMAIN=<BASE_DOMAIN>
BIND_HOST=0.0.0.0
BIND_PORT=443
TLS_CERT_PATH=/etc/relay/tls/fullchain.pem
TLS_KEY_PATH=/etc/relay/tls/privkey.pem
AGENT_BIND_PORT=8444
AGENT_CA_CERT_PATH=/etc/relay/ca/agent-ca.cert.pem
AGENT_CA_CHAIN_PATH=/etc/relay/ca/agent-ca.bundle.pem
RELAY_NODE_ID=relay-1
RELAY_AUTH_VERIFY_PUBKEY=<base64url from step 2b — SAME key as CAPABILITY_SIGN_PUBKEY_B64>
RELAY_TRUST_DOMAIN=<RELAY_TRUST_DOMAIN>
PG_URL=postgres://relay:<POSTGRES_PASSWORD>@127.0.0.1:5432/relay
REDIS_URL=redis://127.0.0.1:6379
```
---
## 4. Open the Aliyun security group (inbound)
Open **only** these, in the ECS instance's security group:
| Port | Who | Note |
|---|---|---|
| `443/tcp` | browsers (WSS) | permanent |
| `8444/tcp` (`AGENT_BIND_PORT`) | agents (mTLS) | permanent |
| `80/tcp` | Let's Encrypt HTTP-01 | **temporary** — only during cert issue/renew; skip entirely if using DNS-01 |
**Keep loopback-only (never open):** Postgres `5432`, Redis `6379`, control-plane admin
`8080`. Reach the admin API from your laptop via SSH tunnel: `ssh -L 8080:127.0.0.1:8080 root@8.138.1.192`.
---
## 5. Build the browser bundle
```bash
cd /opt/web-terminal
npm --prefix relay-web run build # emits relay-web/public/build/, served same-origin by D1
```
---
## 6. Enable + start both units
```bash
sudo cp deploy/systemd/relay-control-plane.service deploy/systemd/relay-data-plane.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now relay-control-plane.service # migrates PG, listens on 127.0.0.1:8080
sudo systemctl enable --now relay-data-plane.service # :443 browser WSS + :8444 agent mTLS
sudo systemctl status relay-control-plane.service relay-data-plane.service
journalctl -u relay-data-plane.service -f # watch for bind + verify-key load
```
---
## 7. Create an account + pairing code (control-plane admin)
The admin API is **deny-by-default**: `POST /accounts` and `POST /accounts/:id/pairing-codes` require a
**capability token** with the `manage` right whose **`aud` equals `BASE_DOMAIN`** (control-plane
`authz.ts` / `main.ts` `expectedAud: env.baseDomain`). `accountId` is taken ONLY from the verified
token (INV3) — never from the body.
Mint the token with the **capability PRIVATE key** from step 2b (the B6 token-mint owns the signing
helper; it imports `/etc/relay/capability/capability-sign.key.pem`). Token claims:
`rights=['manage']`, `aud=<BASE_DOMAIN>`. Send it as `Authorization: Bearer <token>`.
Bootstrap has a two-token nuance (grounded in `provision.ts`):
1. `POST /accounts` checks `manage` only (no account-ownership check) → mint a `manage` token, create
the account, read back its `id`.
2. `POST /accounts/:id/pairing-codes` additionally enforces `token.sub == :id` (own-account). Mint a
second `manage` token with `sub=<the new accountId>`, then request the pairing code.
Over the SSH tunnel from step 4:
```bash
# 1) create account
curl -sS -X POST http://127.0.0.1:8080/accounts \
-H "Authorization: Bearer <MANAGE_TOKEN aud=BASE_DOMAIN>" \
-H 'content-type: application/json' -d '{"plan":"personal"}'
# -> { "id": "<ACCOUNT_ID>", ... }
# 2) pairing code (token.sub must equal <ACCOUNT_ID>)
curl -sS -X POST http://127.0.0.1:8080/accounts/<ACCOUNT_ID>/pairing-codes \
-H "Authorization: Bearer <MANAGE_TOKEN sub=ACCOUNT_ID aud=BASE_DOMAIN>"
# -> { "code": "<PAIRING_CODE>", ... } (single-use, TTL 600s default)
```
---
## 8. On the LAPTOP — build the agent, pair, run
The agent dials OUT; nothing inbound is opened on the laptop.
```bash
cd <repo>/agent
npm ci && npm run build # emits dist/cli.js (C1)
export RELAY_URL="wss://<SUBDOMAIN>.<BASE_DOMAIN>:8444" # AGENT_BIND_PORT
export ENROLL_URL="https://<SUBDOMAIN>.<BASE_DOMAIN>/enroll"
export HOST_ID="<pick-a-host-id>"
export SUBDOMAIN="<SUBDOMAIN>"
export LOCAL_TARGET_URL="ws://127.0.0.1:3000" # the base web-terminal app
export STATE_DIR="$HOME/.web-terminal-agent"
node dist/cli.js pair <PAIRING_CODE> # redeems the code -> SPIFFE mTLS cert + hostContentSecret
node dist/cli.js run # dials the relay, holds the mux tunnel
```
`pair` redeems the single-use code and stores the enrolled SPIFFE cert under `STATE_DIR`; `run` opens
the outbound mTLS tunnel and keeps it alive (heartbeat/backoff).
---
## 9. Browser — log in and click through to the shell
1. Open `https://<SUBDOMAIN>.<BASE_DOMAIN>` (served same-origin as the WSS endpoint).
2. Operator login (B6) mints a short-lived connect capability token.
3. Pick the host → the relay splices browser ↔ agent. The relay only sees **ciphertext** (INV2); the
E2E is browser ↔ agent.
---
## 10. Verify restart-safety + revocation teardown
```bash
# Restart-safety (INV7): bounce the relay while a shell is open — the PTY and host registration survive.
sudo systemctl restart relay-data-plane.service
# the browser auto-reconnects; the session is still there (state is in PG/Redis, not the process).
# Revocation teardown (INV12): revoke the host and watch the tunnel drop within budget.
curl -sS -X DELETE http://127.0.0.1:8080/hosts/<HOST_ID> \
-H "Authorization: Bearer <MANAGE_TOKEN sub=ACCOUNT_ID aud=BASE_DOMAIN>"
# -> control-plane publishes a KillSignal on Redis relay:revocations; the data-plane subscriber
# closes the spliced stream (browser WS closes 4403). Measure revoke -> close latency.
```
---
## Troubleshooting
| Symptom | Meaning | Fix |
|---|---|---|
| Browser WS closes **1013** | `WS_TRY_LATER`**no agent tunnel** for that host (agent offline / not dialed) | Start the agent (`node dist/cli.js run`); check `RELAY_URL` host+port; confirm `:8444` open; watch `journalctl -u relay-data-plane`. |
| Browser WS closes **401** | **bad Origin** (CSWSH exact-match failed) — the `Origin` header is not in the relay's `allowedOrigins` | Open via `https://<SUBDOMAIN>.<BASE_DOMAIN>` (not the raw IP, not `http:`, no stray port). `allowedOrigins` is the port-less `https://<sub>.<BASE_DOMAIN>` on `:443`; must match EXACTLY. Verify `BASE_DOMAIN` in `relay.env`. |
| Agent mTLS closes **4401** | cert chains to the CA but the **pubkey is not enrolled** in the host registry (INV14 registry-gated) | The host was never enrolled or was revoked. Re-run `pair <CODE>` with a fresh code; confirm the CP signed the leaf with `/etc/relay/ca/intermediate.key.pem` (chains to `agent-ca.bundle.pem`). |
| Browser WS closes **4403** | `WS_REVOKED` — the host/session was revoked (expected after step 10) | Re-enroll the host to reconnect. |
| CP fails to boot: "CAPABILITY_SIGN_PUBKEY_B64 must decode to 32 bytes" | wrong key encoding in `control-plane.env` | Use the **base64** value (not base64url) from step 2b for the CP; base64url is the relay's `RELAY_AUTH_VERIFY_PUBKEY`. |
| Admin API returns **401** on `POST /accounts` | missing/expired/foreign-`aud` capability token | Mint a `manage` token with `aud=<BASE_DOMAIN>`, signed by the step-2b private key; send `Authorization: Bearer`. |
| Admin API returns **403** on `/pairing-codes` | token lacks `manage` right, or `token.sub != :id` | Mint with `rights:['manage']` and `sub=<ACCOUNT_ID>` (own-account rule). |
| LE issuance fails (timeout/connection) | HTTP-01 `:80` not reachable, or domain not ICP-filed | Open `:80` in the security group for issuance, or switch `ACME_METHOD=dns-01`; confirm the A-record + ICP filing. |
| `docker compose ps` unhealthy | PG/Redis not up | `docker compose -f deploy/docker-compose.yml logs`; confirm `POSTGRES_PASSWORD` set in `deploy/.env`. |
| Mixed-content / `ws:` blocked in browser | page is HTTPS but WS scheme resolved to `ws:` | Serve over HTTPS; the client follows page scheme (`wss:` on HTTPS, M6). Confirm the LE cert is valid for the FQDN. |

48
deploy/docker-compose.yml Normal file
View File

@@ -0,0 +1,48 @@
# RELAY-PHASE1 · E1 — Postgres + Redis for the rendezvous-relay control-plane (P3) on the VPS.
#
# Both services bind to 127.0.0.1 ONLY — they are the relay's private state, never exposed on the
# public interface (INV9-adjacent; open only :443 + AGENT_PORT in the cloud security group). The
# control-plane / relay processes reach them over loopback.
#
# Usage on 8.138.1.192:
# cp deploy/.env.example deploy/.env # then fill secrets
# docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d
#
# PG_URL for the app = postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:5432/${POSTGRES_DB}
# REDIS_URL for the app = redis://127.0.0.1:6379
services:
postgres:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-relay}
POSTGRES_USER: ${POSTGRES_USER:-relay}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/.env}
ports:
- "127.0.0.1:5432:5432" # loopback-only
volumes:
- relay_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-relay} -d ${POSTGRES_DB:-relay}"]
interval: 5s
timeout: 3s
retries: 10
redis:
image: redis:7
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
ports:
- "127.0.0.1:6379:6379" # loopback-only
volumes:
- relay_redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
volumes:
relay_pgdata:
relay_redisdata:

73
deploy/frp/README.md Normal file
View File

@@ -0,0 +1,73 @@
# Native mTLS Tunnel — per-host runbook (V7)
Bring one local host onto the tunnel so native clients (iOS / Android / desktop) can reach its base
app at `https://<name>.terminal.yaojia.wang` over the VPS, gated **only** by device-certificate mTLS.
See `docs/PLAN_NATIVE_TUNNEL.md` for the full design; this is the short operational checklist.
**Trust model: A — single-owner fleet** (all devices/hosts are the operator's). A shared device-CA
means "the closed set of my devices"; there is **no cross-tenant isolation** — do not enroll
distrusting third parties on this CA (that is Model B, out of scope for v1).
## Files here
| File | Install target on the VPS | Purpose |
|---|---|---|
| `frps.toml.example` | `/etc/relay/frp/frps.toml` (0600) | frps control `:7000` + vhost `:7080` |
| `frpc.example.toml` | on each LOCAL host (0600) | dials the VPS, exposes `127.0.0.1:3000` |
| `../nginx/frp-mtls.conf` | `/etc/nginx/conf.d/frp-mtls.conf` | `:8470` TLS-term + device-CA mTLS + WS proxy |
| `../nginx/stream-sni-additions.md` | (merge into the live stream map) | the two additive SNI routes |
## One-time VPS setup (summarised — see PLAN V1V5)
1. **frps** (`frps.toml.example``/etc/relay/frp/frps.toml`): set `auth.token` = `openssl rand -hex 32`.
2. **frp-client CA** (control-channel mTLS): `bash deploy/scripts/gen-device-ca.sh --kind frp-client`.
3. **device CA** (data-path mTLS): `bash deploy/scripts/gen-device-ca.sh` (KIND=device default).
4. **LE wildcard** `*.terminal.yaojia.wang` (V3) → `/etc/relay/frp-tls/{fullchain,privkey}.pem`.
5. **nginx** `:8470` (`frp-mtls.conf`) + the two stream SNI lines (`stream-sni-additions.md`), then
`nginx -t && systemctl reload nginx`.
## Onboard a host (the repeatable part)
1. **frp-client cert** for this host (control mTLS), issued from the frp-client CA:
```bash
CA_DIR=/etc/relay/frp-client-ca bash deploy/scripts/issue-device-cert.sh <name> ./out-<name>
# use out-<name>/<name>.cert.pem + <name>.key.pem for frpc.certFile/keyFile (ignore the .p12)
```
2. **frpc.toml** on the host from `frpc.example.toml`: set `subdomain=<name>`, the shared
`auth.token`, and the frp-client cert/key paths; `chmod 600 frpc.toml`.
3. **Base app** on the host — the tunnel is safe ONLY with these:
| Var | Value | Why |
|---|---|---|
| `BIND_HOST` | `127.0.0.1` | **Mandatory.** Default `0.0.0.0` exposes an unauth'd shell on the LAN, bypassing mTLS. |
| `PORT` | `3000` | frpc dials `127.0.0.1:3000`. |
| `ALLOWED_ORIGINS` | `https://<name>.terminal.yaojia.wang` | bare host; `:443` also matches (normalized). |
| `IDLE_TTL` | `≥ 86400` | walk-away survival. |
| `USE_TMUX` | `1` (*nix) / `0` (Windows) | cross-restart PTY survival. |
4. **Device cert** for whoever will connect (data-path mTLS), issued from the device CA:
```bash
P12_PASSWORD='...' bash deploy/scripts/issue-device-cert.sh <person-or-device> ./out-dev
# deliver out-dev/<...>.p12 SECURELY: scp / AirDrop / Files — NEVER email.
```
5. **Verify** (M1): no-cert → TLS handshake failure; `curl --cert …/<dev>.cert.pem --key …/<dev>.key.pem
-sI https://<name>.terminal.yaojia.wang/live-sessions` → `200`.
## Revoke a device
```bash
CA_DIR=/etc/relay/device-ca bash deploy/scripts/revoke-device.sh /path/to/<dev>.cert.pem
# copy the regenerated /etc/relay/device-ca/crl.pem to the VPS, then ON THE VPS:
nginx -t && nginx -s reload
```
The revoked cert is rejected fleet-wide the moment nginx reloads the CRL.
## Trust-boundary note (Model A)
- The **frp-client cert + token is a trusted secret**: leaking a host's frp-client cert **and** the
shared token lets an attacker register a subdomain (subdomain-takeover / MITM). Keep both `chmod 600`;
deliver over scp, never email. Revoke a compromised host by rotating the `frp-client-ca` trust
(re-issue the remaining hosts) — device certs are unaffected.
- The **device cert** is the data-path gate. Under Model A any valid device cert can reach any
subdomain and can read `/live-sessions/:id/preview` scrollback — acceptable because every device is
yours. Do not extend this CA to third parties without moving to Model B (per-tenant CA).
- **P-256, never Ed25519** for both CAs (nginx `ssl_verify_client` + iOS client-cert compatibility).

View File

@@ -0,0 +1,31 @@
# frpc.toml — per-host client (Native mTLS tunnel · V7 / M1 / S1).
#
# Copy to the LOCAL host that runs the base app, fill the <PLACEHOLDERS>, chmod 600.
# TEMPLATE ONLY — NO REAL SECRETS committed. frpc dials OUT to the VPS :443; nothing is opened
# inbound on the local host.
#
# frps ≥ v0.52 required (`disableCustomTLSFirstByte` exists ≥ v0.50) — it makes frpc send a real TLS
# ClientHello first byte (0x16) so the VPS nginx `ssl_preread` stream can SNI-route it (PLAN M1 #6).
serverAddr = "8.138.1.192"
serverPort = 443
auth.method = "token"
auth.token = "<FRP_TOKEN>" # same token as frps.toml (/etc/relay/secrets.env)
# --- control-channel TLS: present THIS host's frp-client cert to frps; verify the frps server cert ---
transport.tls.enable = true
transport.tls.serverName = "frp.terminal.yaojia.wang" # SNI the stream routes to frps :7000
transport.tls.disableCustomTLSFirstByte = true # CRITICAL for ssl_preread (real ClientHello first)
transport.tls.certFile = "<host-frp-client.cert.pem>" # issued by gen-device-ca.sh --kind frp-client + issue-device-cert.sh
transport.tls.keyFile = "<host-frp-client.key.pem>" # (0600)
transport.tls.trustedCaFile = "<frps-ctrl-ca.cert.pem>" # CA that signed frps-ctrl.cert.pem (V1)
loginFailExit = false # keep retrying if the VPS/frps is briefly down
[[proxies]]
name = "<name>" # unique per host; frps rejects duplicate subdomains
type = "http"
localIP = "127.0.0.1" # base app must bind loopback (BIND_HOST=127.0.0.1)
localPort = 3000
subdomain = "<name>" # reachable at https://<name>.terminal.yaojia.wang

View File

@@ -0,0 +1,29 @@
# frps.toml — VPS control server (Native mTLS tunnel · V1).
#
# Install target on the VPS: /etc/relay/frp/frps.toml (chmod 600, owned frp:frp).
# This is a TEMPLATE with PLACEHOLDERS ONLY — NO REAL SECRETS are committed.
#
# auth.token: replace <FRP_TOKEN> with `openssl rand -hex 32`, stored in /etc/relay/secrets.env
# (0600). The token is NOT the sole gate — control-channel mTLS below is required too,
# so a leaked token alone cannot register a subdomain (PLAN R3 / V1b, Model A).
#
# Both ports are LOOPBACK-only (nginx stream terminates/routes :443 → these; they must stay absent
# from the Aliyun security group — PLAN P-B).
bindAddr = "127.0.0.1"
bindPort = 7000 # control channel (SNI frp.terminal.yaojia.wang → here, TLS passthrough)
vhostHTTPPort = 7080 # vhost HTTP (nginx :8470 proxies verified requests here by Host)
auth.method = "token"
auth.token = "<FRP_TOKEN>"
subDomainHost = "terminal.yaojia.wang"
# --- control-channel mTLS: token is NOT the sole gate (per-host frp-client CA) ---
transport.tls.force = true
transport.tls.certFile = "/etc/relay/frp-tls/frps-ctrl.cert.pem" # frps control SERVER cert (V1)
transport.tls.keyFile = "/etc/relay/frp-tls/frps-ctrl.key.pem" # (0600)
transport.tls.trustedCaFile = "/etc/relay/frp-client-ca/frp-client-ca.cert.pem" # verifies each host's frpc cert
log.to = "/var/log/frp/frps.log"
log.level = "info"

View File

@@ -0,0 +1,44 @@
# frp-mtls.conf — nginx :8470 TLS-termination + device-CA mTLS + WS-upgrade proxy to frps vhost.
#
# Native mTLS tunnel · V4. Install target: /etc/nginx/conf.d/frp-mtls.conf
# This is a conf.d snippet (safe to ADD — it only introduces a NEW loopback :8470 server + one map;
# it does NOT touch the existing stream :443 map, which is edited separately per stream-sni-additions.md).
#
# Data-path security gate: `ssl_verify_client on` against the DEVICE CA (with CRL) is the ONE gate for
# the tunnelled base app. `on`, NEVER `optional` (PLAN P-D / R2). The public :8470 SERVER cert is the
# LE wildcard (V3); the CLIENT trust anchor is the private device CA (V2) — two different chains.
#
# nginx 1.24: there is NO standalone `http2` directive here and NO `http2 off;` — HTTP/2 is off by
# default on this listen and `http2 off;` is INVALID on 1.24 (would fail `nginx -t`, PLAN R7).
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 127.0.0.1:8470 ssl;
server_name ~^.+\.terminal\.yaojia\.wang$;
ssl_certificate /etc/relay/frp-tls/fullchain.pem; # LE wildcard *.terminal.yaojia.wang (V3)
ssl_certificate_key /etc/relay/frp-tls/privkey.pem;
# --- the data-path security gate ---
ssl_verify_client on; # ON, never `optional`
ssl_client_certificate /etc/relay/device-ca/device-ca.cert.pem;
ssl_verify_depth 1;
ssl_crl /etc/relay/device-ca/crl.pem; # revocation from day one (V2)
location / {
proxy_pass http://127.0.0.1:7080; # frps vhost — routes by Host → subdomain
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Origin https://$host; # needed for Android/curl; harmless for iOS
proxy_set_header X-Client-Cert-CN $ssl_client_s_dn;
proxy_buffering off;
proxy_read_timeout 3600s; # prevents idle-WS proxy kill (default 60s, R6)
proxy_send_timeout 3600s;
}
}

View File

@@ -0,0 +1,45 @@
# Stream SNI additions (Native mTLS tunnel · V5)
Two SNI routes must be **merged into the existing** `stream { map $ssl_preread_server_name … }`
block on the VPS (`/etc/nginx/stream-relay.conf`). They add the frp control channel and the
`*.terminal` data path to the single shared `:443` `ssl_preread` listener.
> **This is a MERGE, not a file to ship.** Do **not** drop a full stream conf here — that would
> clobber the live one. Only the two `map` body lines below are new.
## The two additive lines
Add these **inside the existing `map` body** (exact match wins over wildcard, so order is not
load-bearing, but keep the exact `frp.terminal` line above the `*.terminal` wildcard for clarity):
```nginx
frp.terminal.yaojia.wang 127.0.0.1:7000; # frps control channel (TLS passthrough)
*.terminal.yaojia.wang 127.0.0.1:8470; # nginx :8470 TLS-term + device-CA mTLS (frp-mtls.conf)
```
## Coexistence warning (do NOT retype the existing lines)
The existing `map` body already contains — and MUST be preserved **verbatim**:
```nginx
# ... existing hostnames directive ...
*.term.yaojia.wang 127.0.0.1:8443; # E2E browser relay — DO NOT RETYPE / REORDER
default 127.0.0.1:10443; # xray Reality — DO NOT RETYPE / REORDER
```
- `*.term.yaojia.wang` (browser relay) and `*.terminal.yaojia.wang` (this tunnel) are **different
parent labels** — `term` vs `terminal`. `ssl_preread` matches the full SNI, so they never collide.
- Re-type nothing you did not author. Capture the current body first
(`nginx -T | sed -n '/map \$ssl_preread_server_name/,/}/p'`), then append only the two lines above.
## Deploy gate
```bash
cp /etc/nginx/stream-relay.conf{,.bak.$(date +%s)} # snapshot first
# ...edit: append the two lines into the map body...
nginx -t && systemctl reload nginx # NEVER reload on a failed -t
```
After reload, run the 4-zone SNI oracle (`openssl s_client -servername …` for
`frp.terminal` / `<name>.terminal` / `<name>.term` / default) to confirm all four routes still
resolve to the right backend (PLAN V5 / M1 #7).

View File

@@ -0,0 +1,139 @@
#!/usr/bin/env bash
#
# RELAY-PHASE1 · staging bootstrap (mode B — self-signed TLS, no Let's Encrypt yet).
#
# Idempotent one-shot that prepares EVERYTHING the two services need to boot, EXCEPT starting them:
# 1. strong random POSTGRES_PASSWORD + OPERATOR_PASSWORD (persisted 0600, reused on re-run)
# 2. P5 capability keypair (deploy/scripts/gen-capability-key.sh)
# 3. private agent-enrollment CA (deploy/scripts/gen-agent-ca.sh)
# 4. self-signed browser :443 cert (mode B stand-in for issue-tls-cert.sh)
# 5. /etc/relay/control-plane.env + /etc/relay/relay.env — the REAL env contract read by the code
# (control-plane/src/env.ts + relay-run/src/main-phase1.ts), which is a SUPERSET of .env.example:
# · relay needs AGENT_SERVER_CERT_PATH / AGENT_SERVER_KEY_PATH / ALLOWED_ORIGINS (template omits)
# · relay hosts the STAGING /auth/mint, so it needs CAPABILITY_SIGN_PRIVKEY (base64 PKCS#8 DER)
# + MINT_RATE_SALT. In Phase 1 the relay IS a token minter (staging shortcut); Phase 2 = WebAuthn.
# 6. deploy/.env for docker-compose, then `docker compose up -d` Postgres + Redis (loopback-only).
#
# Secrets are generated in place (0600); NOTHING secret is printed except OPERATOR_PASSWORD (once).
# Private keys are NEVER printed. Config via ENV (staging defaults for THIS deploy):
# BASE_DOMAIN (yaojia.wang) SUBDOMAIN (kai) SERVER_IP (8.138.1.192)
# RELAY_TRUST_DOMAIN (relay.yaojia.wang) RELAY_NODE_ID (relay-1) REPO (repo root)
#
# Verify (syntax): bash -n deploy/scripts/bootstrap-staging.sh
set -euo pipefail
BASE_DOMAIN="${BASE_DOMAIN:-yaojia.wang}"
SUBDOMAIN="${SUBDOMAIN:-kai}"
SERVER_IP="${SERVER_IP:-8.138.1.192}"
RELAY_TRUST_DOMAIN="${RELAY_TRUST_DOMAIN:-relay.${BASE_DOMAIN}}"
RELAY_NODE_ID="${RELAY_NODE_ID:-relay-1}"
FQDN="${SUBDOMAIN}.${BASE_DOMAIN}"
# Repo root = two levels up from this script, unless overridden.
SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
REPO="${REPO:-$SELF}"
ETC=/etc/relay
CAP_DIR="${ETC}/capability"
CA_DIR="${ETC}/ca"
TLS_DIR="${ETC}/tls"
SECRETS="${ETC}/secrets.env"
command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not on PATH" >&2; exit 3; }
command -v docker >/dev/null 2>&1 || { echo "FATAL: docker not on PATH" >&2; exit 3; }
umask 077
mkdir -p "${ETC}" "${TLS_DIR}"
echo "== [1/6] passwords (persisted, reused on re-run) =="
# shellcheck disable=SC1090
[[ -f "${SECRETS}" ]] && source "${SECRETS}"
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 32)}"
OPERATOR_PASSWORD="${OPERATOR_PASSWORD:-$(openssl rand -base64 18 | tr -dc 'A-Za-z0-9' | head -c 24)}"
MINT_RATE_SALT="${MINT_RATE_SALT:-$(openssl rand -hex 16)}"
{
echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
echo "OPERATOR_PASSWORD=${OPERATOR_PASSWORD}"
echo "MINT_RATE_SALT=${MINT_RATE_SALT}"
} > "${SECRETS}"
chmod 600 "${SECRETS}"
echo "== [2/6] P5 capability keypair =="
KEY_DIR="${CAP_DIR}" bash "${REPO}/deploy/scripts/gen-capability-key.sh" > "${ETC}/capability-summary.txt" 2>&1 \
|| { echo "FATAL: gen-capability-key.sh failed:" >&2; cat "${ETC}/capability-summary.txt" >&2; exit 1; }
CAP_B64="$(grep -oE 'CAPABILITY_SIGN_PUBKEY_B64=[^ ]+' "${ETC}/capability-summary.txt" | head -1 | cut -d= -f2-)"
CAP_B64URL="$(grep -oE 'RELAY_AUTH_VERIFY_PUBKEY=[^ ]+' "${ETC}/capability-summary.txt" | head -1 | cut -d= -f2-)"
[[ -n "${CAP_B64}" && -n "${CAP_B64URL}" ]] || { echo "FATAL: could not parse capability pubkeys" >&2; exit 1; }
# The relay ALSO mints operator tokens (staging /auth/mint) → it needs the private key as base64 PKCS#8 DER.
CAP_PRIV_B64="$(openssl pkey -in "${CAP_DIR}/capability-sign.key.pem" -outform DER | base64 | tr -d '\n')"
echo "== [3/6] private agent-enrollment CA (NOT Let's Encrypt) =="
CA_DIR="${CA_DIR}" AGENT_FACING_HOST="${FQDN}" AGENT_FACING_IP="${SERVER_IP}" \
RELAY_TRUST_DOMAIN="${RELAY_TRUST_DOMAIN}" \
bash "${REPO}/deploy/scripts/gen-agent-ca.sh" > "${ETC}/ca-summary.txt" 2>&1 \
|| { echo "FATAL: gen-agent-ca.sh failed:" >&2; cat "${ETC}/ca-summary.txt" >&2; exit 1; }
echo "== [4/6] self-signed browser :443 cert for ${FQDN} (mode B) =="
if [[ ! -f "${TLS_DIR}/privkey.pem" ]]; then
openssl req -x509 -newkey rsa:2048 -nodes -days 825 \
-keyout "${TLS_DIR}/privkey.pem" -out "${TLS_DIR}/fullchain.pem" \
-subj "/CN=${FQDN}" -addext "subjectAltName=DNS:${FQDN},IP:${SERVER_IP}"
fi
chmod 600 "${TLS_DIR}/privkey.pem"; chmod 644 "${TLS_DIR}/fullchain.pem"
echo "== [5/6] env files (REAL contract from env.ts + main-phase1.ts) =="
cat > "${ETC}/control-plane.env" <<EOF
PG_URL=postgres://relay:${POSTGRES_PASSWORD}@127.0.0.1:5432/relay
REDIS_URL=redis://127.0.0.1:6379
CAPABILITY_SIGN_PUBKEY_B64=${CAP_B64}
CA_INTERMEDIATE_KMS_KEY_REF=dev-local
CA_INTERMEDIATE_CERT_PATH=${CA_DIR}/intermediate.cert.pem
NODE_MTLS_TRUST_BUNDLE_PATH=${CA_DIR}/agent-ca.bundle.pem
BASE_DOMAIN=${BASE_DOMAIN}
CP_BIND_HOST=127.0.0.1
CP_BIND_PORT=8080
EOF
chmod 600 "${ETC}/control-plane.env"
cat > "${ETC}/relay.env" <<EOF
BIND_HOST=0.0.0.0
BIND_PORT=443
AGENT_BIND_PORT=8444
TLS_CERT_PATH=${TLS_DIR}/fullchain.pem
TLS_KEY_PATH=${TLS_DIR}/privkey.pem
AGENT_SERVER_CERT_PATH=${CA_DIR}/relay-agent-server.cert.pem
AGENT_SERVER_KEY_PATH=${CA_DIR}/relay-agent-server.key.pem
AGENT_CA_CERT_PATH=${CA_DIR}/agent-ca.cert.pem
AGENT_CA_CHAIN_PATH=${CA_DIR}/agent-ca.bundle.pem
BASE_DOMAIN=${BASE_DOMAIN}
RELAY_NODE_ID=${RELAY_NODE_ID}
RELAY_TRUST_DOMAIN=${RELAY_TRUST_DOMAIN}
ALLOWED_ORIGINS=https://${FQDN}
PG_URL=postgres://relay:${POSTGRES_PASSWORD}@127.0.0.1:5432/relay
REDIS_URL=redis://127.0.0.1:6379
RELAY_AUTH_VERIFY_PUBKEY=${CAP_B64URL}
OPERATOR_PASSWORD=${OPERATOR_PASSWORD}
CAPABILITY_SIGN_PRIVKEY=${CAP_PRIV_B64}
MINT_RATE_SALT=${MINT_RATE_SALT}
EOF
chmod 600 "${ETC}/relay.env"
echo "== [6/6] docker compose up -d (Postgres + Redis, loopback-only) =="
cat > "${REPO}/deploy/.env" <<EOF
POSTGRES_DB=relay
POSTGRES_USER=relay
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
EOF
chmod 600 "${REPO}/deploy/.env"
docker compose --env-file "${REPO}/deploy/.env" -f "${REPO}/deploy/docker-compose.yml" up -d
cat <<SUMMARY
=== bootstrap OK ===
FQDN : ${FQDN}
capability pubkey : b64 ${#CAP_B64} chars / b64url ${#CAP_B64URL} chars (same 32 bytes)
env files (0600) : ${ETC}/control-plane.env ${ETC}/relay.env
OPERATOR_PASSWORD : ${OPERATOR_PASSWORD} <-- browser login; shown once, also in ${SECRETS} (0600)
Next: start control-plane + relay, then mint a manage token to create an account + pairing code.
SUMMARY

145
deploy/scripts/gen-agent-ca.sh Executable file
View File

@@ -0,0 +1,145 @@
#!/usr/bin/env bash
#
# RELAY-PHASE1 · E (TASK E) — Private ENROLLMENT CA for agent mTLS.
#
# STAGING TEMPLATE. This mints the PRIVATE trust chain the rendezvous-relay uses to (a) verify agent
# client certs (SPIFFE mTLS, INV14) and (b) present the relay's agent-facing SERVER cert on
# AGENT_BIND_PORT so the dialing agent can authenticate the relay.
#
# *** THIS IS NOT LET'S ENCRYPT. *** The browser-facing :443 cert is public-web PKI and is issued
# by deploy/scripts/issue-tls-cert.sh. This CA is a SEPARATE, private trust root that must NEVER be
# a public CA — mixing them would let any web-PKI leaf enroll as an agent.
#
# Hierarchy produced (Ed25519, to match the control-plane dev signer's algorithm):
# root (self-signed, offline anchor)
# └── intermediate (signs agent SPIFFE leaves at CP /enroll, AND the relay agent-server cert)
# ├── relay agent-server leaf (TLS serverAuth, CN/SAN = the agent-facing host)
# └── (agent leaves are issued at runtime by the control-plane, not here)
#
# Outputs (0600 keys, 0644 certs) under CA_DIR (default /etc/relay/ca):
# root.key.pem root.cert.pem
# intermediate.key.pem intermediate.cert.pem <- CA_INTERMEDIATE_CERT_PATH (+ its key = the CP signer)
# agent-ca.bundle.pem = intermediate + root <- AGENT_CA_CHAIN_PATH / NODE_MTLS_TRUST_BUNDLE_PATH
# agent-ca.cert.pem = intermediate cert <- AGENT_CA_CERT_PATH
# relay-agent-server.key.pem relay-agent-server.cert.pem (present on AGENT_BIND_PORT)
#
# Config via ENV only (no hardcoded hosts/ports/secrets):
# CA_DIR output dir (default /etc/relay/ca)
# AGENT_FACING_HOST REQUIRED — CN/SAN for the relay agent-server cert; the host the agent dials,
# e.g. "<sub>.<BASE_DOMAIN>" (must match RELAY_URL's host in the agent config).
# AGENT_FACING_IP OPTIONAL — extra IP SAN (e.g. 8.138.1.192) if the agent dials the raw IP.
# RELAY_TRUST_DOMAIN OPTIONAL — SPIFFE trust domain (recorded in a NOTE only; agent SPIFFE leaves
# are minted by the control-plane /enroll, not by this script).
# CA_DAYS_ROOT / CA_DAYS_INT / CA_DAYS_LEAF cert lifetimes (defaults 3650 / 1825 / 825).
#
# Verify (syntax): bash -n deploy/scripts/gen-agent-ca.sh
set -euo pipefail
CA_DIR="${CA_DIR:-/etc/relay/ca}"
CA_DAYS_ROOT="${CA_DAYS_ROOT:-3650}"
CA_DAYS_INT="${CA_DAYS_INT:-1825}"
CA_DAYS_LEAF="${CA_DAYS_LEAF:-825}"
if [[ -z "${AGENT_FACING_HOST:-}" ]]; then
echo "FATAL: AGENT_FACING_HOST is required (CN/SAN of the relay agent-server cert)." >&2
echo " e.g. AGENT_FACING_HOST=term1.example.com bash $0" >&2
exit 2
fi
command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not found on PATH." >&2; exit 3; }
umask 077
mkdir -p "${CA_DIR}"
cd "${CA_DIR}"
echo "== Enrollment CA (PRIVATE — not Let's Encrypt) into ${CA_DIR} =="
# ---- 1. Root CA (offline anchor) ------------------------------------------------------------------
if [[ ! -f root.key.pem ]]; then
openssl genpkey -algorithm ed25519 -out root.key.pem
fi
openssl req -x509 -new -key root.key.pem -days "${CA_DAYS_ROOT}" \
-subj "/O=web-terminal-relay/OU=agent-enrollment/CN=web-terminal Agent Enrollment Root" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-addext "subjectKeyIdentifier=hash" \
-out root.cert.pem
# ---- 2. Intermediate CA (the control-plane's leaf-signing key in staging) --------------------------
# The CP dev in-process signer (CA_INTERMEDIATE_KMS_KEY_REF=dev-local) signs agent SPIFFE leaves with
# THIS intermediate key. In Phase 2 the intermediate key moves into a non-exportable KMS/HSM (§3.1).
if [[ ! -f intermediate.key.pem ]]; then
openssl genpkey -algorithm ed25519 -out intermediate.key.pem
fi
openssl req -new -key intermediate.key.pem \
-subj "/O=web-terminal-relay/OU=agent-enrollment/CN=web-terminal Agent Enrollment Intermediate" \
-out intermediate.csr.pem
cat > intermediate.ext <<'EXT'
basicConstraints = critical,CA:TRUE,pathlen:0
keyUsage = critical,keyCertSign,cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always
EXT
openssl x509 -req -in intermediate.csr.pem \
-CA root.cert.pem -CAkey root.key.pem -CAcreateserial \
-days "${CA_DAYS_INT}" -extfile intermediate.ext \
-out intermediate.cert.pem
# ---- 3. Trust bundle (intermediate + root) --------------------------------------------------------
# The relay walks leaf -> intermediate -> root, so the anchor (root) MUST be in the bundle
# (relay-auth verifyChain terminates at a self-signed root present in the set, INV14).
cat intermediate.cert.pem root.cert.pem > agent-ca.bundle.pem
cp intermediate.cert.pem agent-ca.cert.pem
# ---- 4. Relay agent-facing SERVER cert (presented on AGENT_BIND_PORT) -----------------------------
# The agent dials wss://<AGENT_FACING_HOST>:<AGENT_BIND_PORT> and pins THIS CA to authenticate the
# relay server. CN/SAN must equal the host the agent dials.
if [[ ! -f relay-agent-server.key.pem ]]; then
openssl genpkey -algorithm ed25519 -out relay-agent-server.key.pem
fi
openssl req -new -key relay-agent-server.key.pem \
-subj "/O=web-terminal-relay/OU=agent-data-plane/CN=${AGENT_FACING_HOST}" \
-out relay-agent-server.csr.pem
{
echo "basicConstraints = critical,CA:FALSE"
echo "keyUsage = critical,digitalSignature"
echo "extendedKeyUsage = serverAuth"
echo "subjectKeyIdentifier = hash"
echo "authorityKeyIdentifier = keyid,issuer"
if [[ -n "${AGENT_FACING_IP:-}" ]]; then
echo "subjectAltName = DNS:${AGENT_FACING_HOST},IP:${AGENT_FACING_IP}"
else
echo "subjectAltName = DNS:${AGENT_FACING_HOST}"
fi
} > relay-agent-server.ext
openssl x509 -req -in relay-agent-server.csr.pem \
-CA intermediate.cert.pem -CAkey intermediate.key.pem -CAcreateserial \
-days "${CA_DAYS_LEAF}" -extfile relay-agent-server.ext \
-out relay-agent-server.cert.pem
# ---- 5. Permissions + summary ---------------------------------------------------------------------
chmod 600 ./*.key.pem
chmod 644 ./*.cert.pem ./*.bundle.pem
rm -f ./*.csr.pem ./*.ext
cat <<SUMMARY
Enrollment CA ready in ${CA_DIR}. Wire these env paths (relay.env / control-plane.env):
CA_INTERMEDIATE_CERT_PATH = ${CA_DIR}/intermediate.cert.pem
NODE_MTLS_TRUST_BUNDLE_PATH = ${CA_DIR}/agent-ca.bundle.pem
AGENT_CA_CERT_PATH = ${CA_DIR}/agent-ca.cert.pem
AGENT_CA_CHAIN_PATH = ${CA_DIR}/agent-ca.bundle.pem
relay agent-server cert = ${CA_DIR}/relay-agent-server.cert.pem
relay agent-server key = ${CA_DIR}/relay-agent-server.key.pem (0600)
intermediate signing key = ${CA_DIR}/intermediate.key.pem (0600 — the CP dev signer)
NOTE (staging integration seam, owned by the A-wave CP boot/ca-wiring, NOT by this script):
The control-plane's dev in-process signer must sign agent leaves with intermediate.key.pem so the
leaves chain to this bundle. CA_INTERMEDIATE_KMS_KEY_REF=dev-local selects the dev signer; point it
at ${CA_DIR}/intermediate.key.pem. Phase 2 replaces this with a KMS/HSM ref (§3.1).
SPIFFE trust domain for agent leaves = ${RELAY_TRUST_DOMAIN:-<RELAY_TRUST_DOMAIN unset>}.
*** root.key.pem is the offline trust anchor — after first use, move it OFF this host. ***
SUMMARY

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bash
#
# RELAY-PHASE1 · E (TASK E) — P5 capability-token keypair (Ed25519).
#
# STAGING TEMPLATE. One Ed25519 keypair underpins the whole authz split:
#
# CONTROL-PLANE SIGNS ──(mints capability tokens with the PRIVATE key, B6 token-mint)──▶
# RELAY VERIFIES
# The CP admin API (POST /accounts, /pairing-codes) ALSO verifies operator "manage" tokens with
# the SAME public key. So the public key is deployed to BOTH processes; the private key lives ONLY
# where tokens are minted (the B6 mint), never on the relay, never committed.
#
# The public key is printed in TWO encodings of the SAME raw 32 bytes (the split is encoding-only):
# CAPABILITY_SIGN_PUBKEY_B64 raw 32B, standard base64 -> control-plane env (env.ts base64ToBytes)
# RELAY_AUTH_VERIFY_PUBKEY raw 32B, base64url (no pad) -> relay env (loadVerifyKeyFromEnv)
# These MUST be the SAME key (PLAN §3 linchpin). This script prints both from one keypair so they
# cannot drift.
#
# The PRIVATE key is written PKCS#8 PEM to a 0600 file for the B6 mint to import (WebCrypto Ed25519).
# It is NEVER printed and NEVER placed in relay.env / control-plane.env.
#
# Config via ENV only:
# KEY_DIR output dir for the private key (default /etc/relay/capability)
#
# Verify (syntax): bash -n deploy/scripts/gen-capability-key.sh
set -euo pipefail
KEY_DIR="${KEY_DIR:-/etc/relay/capability}"
PRIV="${KEY_DIR}/capability-sign.key.pem"
command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not found on PATH." >&2; exit 3; }
umask 077
mkdir -p "${KEY_DIR}"
if [[ -f "${PRIV}" ]]; then
echo "WARN: ${PRIV} already exists — reusing it (delete it to rotate). Rotating INVALIDATES every" >&2
echo " minted token AND requires re-deploying the public key to BOTH processes." >&2
else
openssl genpkey -algorithm ed25519 -out "${PRIV}"
fi
chmod 600 "${PRIV}"
# Raw 32-byte Ed25519 public key = last 32 bytes of the SPKI DER.
PUB_STD_B64="$(openssl pkey -in "${PRIV}" -pubout -outform DER | tail -c 32 | openssl base64 -A)"
# base64url of the SAME bytes, padding stripped (decodeBase64UrlBytes tolerates missing padding).
PUB_B64URL="$(printf '%s' "${PUB_STD_B64}" | tr '+/' '-_' | tr -d '=')"
cat <<SUMMARY
P5 capability keypair ready.
PRIVATE key (B6 token-mint only; 0600; NEVER commit / NEVER on the relay):
${PRIV}
Put the SAME public key in BOTH env files (they are one key, two encodings):
# control-plane.env
CAPABILITY_SIGN_PUBKEY_B64=${PUB_STD_B64}
# relay.env
RELAY_AUTH_VERIFY_PUBKEY=${PUB_B64URL}
Sanity check they decode to the same 32 bytes:
diff <(openssl pkey -in "${PRIV}" -pubout -outform DER | tail -c 32 | xxd -p) \\
<(printf '%s' "${PUB_STD_B64}" | openssl base64 -d -A | xxd -p) && echo OK
SUMMARY

163
deploy/scripts/gen-device-ca.sh Executable file
View File

@@ -0,0 +1,163 @@
#!/usr/bin/env bash
#
# RELAY-PHASE1 · Native mTLS tunnel · V2 — Device client-CA (and frp-client control-CA).
#
# STAGING TEMPLATE. Mints a PRIVATE client-authentication CA whose leaves the native clients (iOS /
# Android / desktop) present at nginx :8470 (`ssl_verify_client on`). This CA is the ONE AND ONLY
# security gate for the tunnelled base app (the base app has no login of its own — PLAN §1), so it
# MUST be a SEPARATE trust root from Let's Encrypt (the public :8470 SERVER cert) and from the
# agent-enrollment CA (deploy/scripts/gen-agent-ca.sh). Mixing them would let a foreign leaf enroll.
#
# *** P-256 (prime256v1), NEVER Ed25519. *** nginx `ssl_verify_client` + iOS client-certificate
# selection require ECDSA-P256/RSA; Ed25519 client certs are not universally accepted (PLAN V2).
#
# Two KINDs (same structure, different name/subject/default dir):
# device → /etc/relay/device-ca (device-ca.cert.pem) leaves = client devices (V2/V4)
# frp-client → /etc/relay/frp-client-ca (frp-client-ca.cert.pem) leaves = per-host frpc control mTLS (V1/V1b)
#
# Produces an OpenSSL `ca` database (index.txt / serial / crlnumber / newcerts / openssl.cnf) so that
# deploy/scripts/issue-device-cert.sh can sign tracked leaves and deploy/scripts/revoke-device.sh can
# revoke by serial and regenerate the CRL. An EMPTY crl.pem is initialised NOW so V4's `ssl_crl` has a
# file to reference from day one.
#
# Outputs (0600 key, 0644 cert) under CA_DIR:
# <CA_NAME>.key.pem <CA_NAME>.cert.pem crl.pem
# index.txt serial crlnumber index.txt.attr newcerts/ openssl.cnf (the `openssl ca` db)
#
# Config via ENV or flags (no hardcoded secrets — this CA key is generated in place, never committed):
# KIND device | frp-client (default device) — or --kind <k>
# CA_DIR output dir (default per-KIND above) — or --dir <path>
# CA_DAYS CA lifetime in days (default 3650)
#
# Idempotent: an existing CA (key+cert) is REUSED, never overwritten (delete the dir to rotate).
#
# Verify (syntax): bash -n deploy/scripts/gen-device-ca.sh
set -euo pipefail
KIND="${KIND:-device}"
CA_DIR="${CA_DIR:-}"
CA_DAYS="${CA_DAYS:-3650}"
# ---- args (flags override env) --------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--kind) KIND="${2:?--kind needs a value}"; shift 2 ;;
--dir) CA_DIR="${2:?--dir needs a value}"; shift 2 ;;
-h|--help)
grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "FATAL: unknown argument '$1' (see --help)" >&2; exit 2 ;;
esac
done
case "${KIND}" in
device) CA_NAME="device-ca"; CA_CN="web-terminal Device CA"; CA_OU="device-ca"
CA_DIR="${CA_DIR:-/etc/relay/device-ca}" ;;
frp-client) CA_NAME="frp-client-ca"; CA_CN="web-terminal frp-client CA"; CA_OU="frp-client-ca"
CA_DIR="${CA_DIR:-/etc/relay/frp-client-ca}" ;;
*) echo "FATAL: KIND must be 'device' or 'frp-client' (got '${KIND}')." >&2; exit 2 ;;
esac
command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not found on PATH." >&2; exit 3; }
umask 077
mkdir -p "${CA_DIR}/newcerts"
# Resolve to an absolute path so `openssl ca` (which we run cd'd into CA_DIR with dir=.) is stable.
CA_DIR="$(cd "${CA_DIR}" && pwd)"
KEY="${CA_DIR}/${CA_NAME}.key.pem"
CERT="${CA_DIR}/${CA_NAME}.cert.pem"
echo "== Device/frp-client CA (${KIND}, PRIVATE — not Let's Encrypt) into ${CA_DIR} =="
# ---- 1. CA keypair + self-signed cert (P-256, CA:TRUE, keyCertSign+cRLSign) -----------------------
if [[ -f "${KEY}" && -f "${CERT}" ]]; then
echo ">> CA already exists — reusing (delete ${CA_DIR} to rotate)."
else
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "${KEY}"
openssl req -x509 -new -key "${KEY}" -days "${CA_DAYS}" \
-subj "/O=web-terminal-relay/OU=${CA_OU}/CN=${CA_CN}" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-addext "subjectKeyIdentifier=hash" \
-out "${CERT}"
fi
# ---- 2. OpenSSL `ca` database (so leaves are tracked → revocable via the CRL) ----------------------
[[ -f "${CA_DIR}/index.txt" ]] || : > "${CA_DIR}/index.txt"
[[ -f "${CA_DIR}/serial" ]] || echo 1000 > "${CA_DIR}/serial"
[[ -f "${CA_DIR}/crlnumber" ]] || echo 1000 > "${CA_DIR}/crlnumber"
# unique_subject=no allows re-issuing a leaf for the same CN (device/host cert rotation).
[[ -f "${CA_DIR}/index.txt.attr" ]] || echo "unique_subject = no" > "${CA_DIR}/index.txt.attr"
if [[ ! -f "${CA_DIR}/openssl.cnf" ]]; then
# dir = . → the issue/revoke scripts `cd` into CA_DIR before invoking `openssl ca`, so the db is
# relocatable (works in a mktemp test dir). $dir is expanded by openssl; ${CA_NAME} by bash here.
cat > "${CA_DIR}/openssl.cnf" <<EOF
[ ca ]
default_ca = CA_default
[ CA_default ]
dir = .
database = \$dir/index.txt
serial = \$dir/serial
new_certs_dir = \$dir/newcerts
certificate = \$dir/${CA_NAME}.cert.pem
private_key = \$dir/${CA_NAME}.key.pem
crlnumber = \$dir/crlnumber
crl = \$dir/crl.pem
default_md = sha256
default_days = 825
default_crl_days = 30
policy = policy_anything
copy_extensions = none
x509_extensions = client_cert
crl_extensions = crl_ext
[ policy_anything ]
countryName = optional
stateOrProvinceName = optional
localityName = optional
organizationName = optional
organizationalUnitName = optional
commonName = supplied
emailAddress = optional
# Leaf profile: client-authentication ONLY (never serverAuth, never CA).
[ client_cert ]
basicConstraints = critical, CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = clientAuth
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
[ crl_ext ]
authorityKeyIdentifier = keyid:always
EOF
fi
# ---- 3. Empty CRL now (V4 `ssl_crl` needs the file to exist even with zero revocations) ------------
if [[ ! -f "${CA_DIR}/crl.pem" ]]; then
( cd "${CA_DIR}" && openssl ca -config openssl.cnf -gencrl -out crl.pem >/dev/null 2>&1 )
fi
# ---- 4. Permissions + summary ---------------------------------------------------------------------
chmod 700 "${CA_DIR}"
chmod 600 "${KEY}"
chmod 644 "${CERT}" "${CA_DIR}/crl.pem"
cat <<SUMMARY
${KIND} CA ready in ${CA_DIR}:
CA cert = ${CERT} (0644 — the trust anchor to deploy)
CA key = ${KEY} (0600 — NEVER commit / NEVER leave a shared host)
CRL = ${CA_DIR}/crl.pem (empty; regenerated by revoke-device.sh)
ca db = index.txt / serial / crlnumber / newcerts / openssl.cnf
Wire it:
device → nginx :8470 ssl_client_certificate ${CERT} ; ssl_crl ${CA_DIR}/crl.pem ;
frp-client → frps.toml transport.tls.trustedCaFile = ${CERT}
Next:
issue a leaf : CA_DIR=${CA_DIR} bash deploy/scripts/issue-device-cert.sh <CN> <out-dir>
revoke a leaf : CA_DIR=${CA_DIR} bash deploy/scripts/revoke-device.sh <leaf.cert.pem>
SUMMARY

View File

@@ -0,0 +1,120 @@
#!/usr/bin/env bash
#
# RELAY-PHASE1 · Native mTLS tunnel · V2 — Issue a client-auth LEAF from the device CA.
#
# STAGING TEMPLATE. Signs a P-256 client-certificate (EKU=clientAuth, 825d) with the CA minted by
# deploy/scripts/gen-device-ca.sh and emits BOTH delivery formats:
# <CN>.p12 — PKCS#12, `-legacy` (3DES/RC2) for iOS / Android import compatibility (V2)
# <CN>.cert.pem — leaf cert ┐
# <CN>.key.pem — leaf private key (0600) ├─ desktop / curl --cert (M1 acceptance)
# <CN>.fullchain.pem— leaf + CA cert ┘
# The leaf is signed via `openssl ca`, so it is recorded in the CA's index.txt and can later be
# revoked (CRL) by deploy/scripts/revoke-device.sh. CN + serial are appended to <CA_DIR>/issued.log.
#
# NOTE: the SAME script issues the per-host frp-client control certs (V1b) — just point CA_DIR at the
# frp-client CA: CA_DIR=/etc/relay/frp-client-ca bash issue-device-cert.sh <hostname> <out>
# (frpc uses the .cert.pem/.key.pem; ignore the .p12 for that use.)
#
# Usage: issue-device-cert.sh <CN> [OUT_DIR]
# <CN> required — the device/host common name (also the filename stem); [A-Za-z0-9._-]+
# [OUT_DIR] optional — where the leaf artifacts land (default <CA_DIR>/issued/<CN>)
#
# Config via ENV or flags (no hardcoded secrets):
# CA_DIR which CA signs (default /etc/relay/device-ca) — or --ca-dir <path>
# P12_PASSWORD .p12 export passphrase (REQUIRED via env/arg; auto-generated + printed ONCE if
# unset) — or --pass <secret>
# LEAF_DAYS leaf lifetime (default 825)
#
# Verify (syntax): bash -n deploy/scripts/issue-device-cert.sh
set -euo pipefail
CA_DIR="${CA_DIR:-/etc/relay/device-ca}"
LEAF_DAYS="${LEAF_DAYS:-825}"
CN=""
OUT_DIR=""
# ---- args ----------------------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--ca-dir) CA_DIR="${2:?--ca-dir needs a value}"; shift 2 ;;
--pass) P12_PASSWORD="${2:?--pass needs a value}"; shift 2 ;;
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
-*) echo "FATAL: unknown flag '$1' (see --help)" >&2; exit 2 ;;
*) if [[ -z "${CN}" ]]; then CN="$1"; elif [[ -z "${OUT_DIR}" ]]; then OUT_DIR="$1";
else echo "FATAL: too many positional args at '$1'" >&2; exit 2; fi; shift ;;
esac
done
[[ -n "${CN}" ]] || { echo "FATAL: <CN> is required. usage: issue-device-cert.sh <CN> [OUT_DIR]" >&2; exit 2; }
# Boundary validation: CN becomes a filename — reject anything that could traverse or surprise.
[[ "${CN}" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "FATAL: CN '${CN}' must match [A-Za-z0-9._-]+ (it is used as a filename)." >&2; exit 2; }
command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not found on PATH." >&2; exit 3; }
CA_CERT="${CA_DIR}/$(basename "${CA_DIR}").cert.pem"
# gen-device-ca names the CA cert after its dir (device-ca.cert.pem / frp-client-ca.cert.pem); if the
# dir was renamed, fall back to the single *.cert.pem that is not a leaf. Keep it explicit + validated.
if [[ ! -f "${CA_CERT}" ]]; then
CA_CERT="$(ls "${CA_DIR}"/*-ca.cert.pem 2>/dev/null | head -1 || true)"
fi
[[ -f "${CA_DIR}/openssl.cnf" && -f "${CA_CERT}" ]] || {
echo "FATAL: no CA db in ${CA_DIR}. Run gen-device-ca.sh first (expected openssl.cnf + *-ca.cert.pem)." >&2
exit 3; }
OUT_DIR="${OUT_DIR:-${CA_DIR}/issued/${CN}}"
umask 077
mkdir -p "${OUT_DIR}"
OUT_DIR="$(cd "${OUT_DIR}" && pwd)" # absolute — we cd into CA_DIR before `openssl ca`
# ---- passphrase (never hardcoded; auto-generate + print once if not supplied) ---------------------
P12_GENERATED=0
if [[ -z "${P12_PASSWORD:-}" ]]; then
P12_PASSWORD="$(openssl rand -base64 18 | tr -dc 'A-Za-z0-9' | head -c 24)"
P12_GENERATED=1
fi
KEY="${OUT_DIR}/${CN}.key.pem"
CERT="${OUT_DIR}/${CN}.cert.pem"
CHAIN="${OUT_DIR}/${CN}.fullchain.pem"
P12="${OUT_DIR}/${CN}.p12"
CSR="${OUT_DIR}/${CN}.csr.pem"
echo "== Issue client-auth leaf CN=${CN} (${LEAF_DAYS}d) signed by ${CA_CERT}${OUT_DIR} =="
# ---- 1. leaf keypair + CSR (P-256) ----------------------------------------------------------------
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "${KEY}"
openssl req -new -key "${KEY}" -subj "/O=web-terminal-relay/OU=device/CN=${CN}" -out "${CSR}"
# ---- 2. sign via `openssl ca` (tracks the leaf in index.txt → revocable) ---------------------------
( cd "${CA_DIR}" && openssl ca -config openssl.cnf -batch -notext \
-extensions client_cert -days "${LEAF_DAYS}" \
-in "${CSR}" -out "${CERT}" )
cat "${CERT}" "${CA_CERT}" > "${CHAIN}"
# ---- 3. PKCS#12 for iOS / Android (legacy 3DES/RC2 — imports reliably on mobile) -------------------
openssl pkcs12 -export -legacy \
-inkey "${KEY}" -in "${CERT}" -certfile "${CA_CERT}" \
-name "${CN}" -passout "pass:${P12_PASSWORD}" -out "${P12}"
# ---- 4. bookkeeping -------------------------------------------------------------------------------
SERIAL="$(openssl x509 -in "${CERT}" -noout -serial | cut -d= -f2)"
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') CN=${CN} serial=${SERIAL} out=${OUT_DIR}" >> "${CA_DIR}/issued.log"
rm -f "${CSR}"
chmod 600 "${KEY}" "${P12}"
chmod 644 "${CERT}" "${CHAIN}"
cat <<SUMMARY
Leaf issued for CN=${CN} (serial ${SERIAL}):
mobile (iOS/Android) : ${P12} (0600) import + passphrase in-app
desktop / curl : ${CERT}
${KEY} (0600)
${CHAIN}
logged : ${CA_DIR}/issued.log
Deliver the .p12 SECURELY (scp / AirDrop / Files — NEVER email).
$( [[ "${P12_GENERATED}" == "1" ]] && printf ' P12 PASSPHRASE (shown ONCE — record it now): %s\n' "${P12_PASSWORD}" )
Revoke later: CA_DIR=${CA_DIR} bash deploy/scripts/revoke-device.sh ${CERT}
SUMMARY

115
deploy/scripts/issue-tls-cert.sh Executable file
View File

@@ -0,0 +1,115 @@
#!/usr/bin/env bash
#
# RELAY-PHASE1 · E (TASK E) — PUBLIC-web TLS cert for the browser :443 endpoint (Let's Encrypt).
#
# STAGING TEMPLATE. This is the ONLY public-CA cert in the deployment: the browser hits
# https://<SUBDOMAIN>.<BASE_DOMAIN> (WSS same-origin), so it must chain to a public root. It is a
# DIFFERENT trust chain from the private agent-enrollment CA (deploy/scripts/gen-agent-ca.sh) — do
# NOT cross-wire them.
#
# ICP-備案 ASSUMPTION (mainland Alibaba Cloud): BASE_DOMAIN is ALREADY ICP-filed and its A-record
# <SUBDOMAIN>.<BASE_DOMAIN> -> 8.138.1.192 resolves. Serving :80/:443 on an unfiled domain in
# mainland CN is blocked upstream; HTTP-01 will then fail. If unfiled, use DNS-01 (no inbound :80).
#
# Two challenge methods (pick with ACME_METHOD):
# http-01 — a one-shot listener on :80 answers the challenge. REQUIRES inbound :80 open in the
# Aliyun security group DURING issuance (you can close it again after; renewals reopen).
# dns-01 — a TXT record under _acme-challenge.<SUBDOMAIN>.<BASE_DOMAIN>. No inbound :80. Needs the
# acme.sh DNS-API creds for your provider (e.g. Ali_Key/Ali_Secret for the Aliyun DNS API).
#
# Installs to TLS_CERT_PATH (fullchain) + TLS_KEY_PATH, matching relay.env.
#
# Config via ENV only:
# BASE_DOMAIN REQUIRED (e.g. term.example.com)
# SUBDOMAIN REQUIRED (the tenant sub; FQDN = <SUBDOMAIN>.<BASE_DOMAIN>)
# ACME_EMAIL REQUIRED account/registration email
# ACME_METHOD http-01 | dns-01 (default http-01)
# ACME_CLIENT acme.sh | certbot (default acme.sh)
# ACME_DNS_PROVIDER acme.sh dnsapi id for dns-01 (e.g. dns_ali) (dns-01 only)
# TLS_CERT_PATH install target for fullchain (default /etc/relay/tls/fullchain.pem)
# TLS_KEY_PATH install target for privkey (default /etc/relay/tls/privkey.pem)
# ACME_STAGING 1 to use the LE staging CA (untrusted, avoids rate limits while testing)
#
# Verify (syntax): bash -n deploy/scripts/issue-tls-cert.sh
set -euo pipefail
: "${BASE_DOMAIN:?FATAL: BASE_DOMAIN is required (your ICP-filed domain)}"
: "${SUBDOMAIN:?FATAL: SUBDOMAIN is required}"
: "${ACME_EMAIL:?FATAL: ACME_EMAIL is required}"
ACME_METHOD="${ACME_METHOD:-http-01}"
ACME_CLIENT="${ACME_CLIENT:-acme.sh}"
TLS_CERT_PATH="${TLS_CERT_PATH:-/etc/relay/tls/fullchain.pem}"
TLS_KEY_PATH="${TLS_KEY_PATH:-/etc/relay/tls/privkey.pem}"
FQDN="${SUBDOMAIN}.${BASE_DOMAIN}"
echo "== Issuing Let's Encrypt cert for ${FQDN} via ${ACME_CLIENT} (${ACME_METHOD}) =="
mkdir -p "$(dirname "${TLS_CERT_PATH}")" "$(dirname "${TLS_KEY_PATH}")"
case "${ACME_CLIENT}" in
acme.sh)
command -v acme.sh >/dev/null 2>&1 || { echo "FATAL: acme.sh not installed. curl https://get.acme.sh | sh" >&2; exit 3; }
acme.sh --register-account -m "${ACME_EMAIL}" >/dev/null 2>&1 || true
STAGING_FLAG=""
[[ "${ACME_STAGING:-0}" == "1" ]] && STAGING_FLAG="--staging"
case "${ACME_METHOD}" in
http-01)
echo ">> Ensure inbound :80 is OPEN in the Aliyun security group for the duration of issuance."
acme.sh --issue ${STAGING_FLAG} -d "${FQDN}" --standalone --httpport 80
;;
dns-01)
: "${ACME_DNS_PROVIDER:?FATAL: ACME_DNS_PROVIDER required for dns-01 (e.g. dns_ali); export the DNS-API creds too}"
acme.sh --issue ${STAGING_FLAG} -d "${FQDN}" --dns "${ACME_DNS_PROVIDER}"
;;
*)
echo "FATAL: ACME_METHOD must be http-01 or dns-01" >&2; exit 2 ;;
esac
# --install-cert copies the current material AND registers the renew-reload hook.
acme.sh --install-cert -d "${FQDN}" \
--key-file "${TLS_KEY_PATH}" \
--fullchain-file "${TLS_CERT_PATH}" \
--reloadcmd "systemctl try-reload-or-restart relay-data-plane.service"
;;
certbot)
command -v certbot >/dev/null 2>&1 || { echo "FATAL: certbot not installed (apt-get install certbot)." >&2; exit 3; }
STAGING_FLAG=""
[[ "${ACME_STAGING:-0}" == "1" ]] && STAGING_FLAG="--staging"
case "${ACME_METHOD}" in
http-01)
echo ">> Ensure inbound :80 is OPEN in the Aliyun security group for the duration of issuance."
certbot certonly ${STAGING_FLAG} --standalone --non-interactive --agree-tos \
-m "${ACME_EMAIL}" -d "${FQDN}"
;;
dns-01)
echo "FATAL: certbot dns-01 needs a provider plugin; prefer ACME_CLIENT=acme.sh for Aliyun DNS." >&2
exit 2 ;;
*)
echo "FATAL: ACME_METHOD must be http-01 or dns-01" >&2; exit 2 ;;
esac
install -m 644 "/etc/letsencrypt/live/${FQDN}/fullchain.pem" "${TLS_CERT_PATH}"
install -m 600 "/etc/letsencrypt/live/${FQDN}/privkey.pem" "${TLS_KEY_PATH}"
;;
*)
echo "FATAL: ACME_CLIENT must be acme.sh or certbot" >&2; exit 2 ;;
esac
chmod 600 "${TLS_KEY_PATH}" || true
chmod 644 "${TLS_CERT_PATH}" || true
cat <<SUMMARY
Installed:
TLS_CERT_PATH = ${TLS_CERT_PATH}
TLS_KEY_PATH = ${TLS_KEY_PATH}
RENEWAL (LE certs last ~90 days):
- acme.sh installs its OWN cron on install ('acme.sh --cron'); --install-cert registered the
reload hook above, so renewals auto-reload the data-plane. Verify: 'crontab -l | grep acme'.
- certbot: add a cron/timer, e.g.
0 3 * * * certbot renew --quiet --deploy-hook 'systemctl try-reload-or-restart relay-data-plane.service'
- http-01 renewals need :80 reachable at renew time; dns-01 does not.
SUMMARY

68
deploy/scripts/revoke-device.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
#
# RELAY-PHASE1 · Native mTLS tunnel · V2 — Revoke a device/frp-client leaf, regenerate the CRL.
#
# STAGING TEMPLATE. Marks a previously-issued leaf revoked in the CA database and regenerates
# <CA_DIR>/crl.pem, which nginx :8470 serves via `ssl_crl` (V4) — so the revoked device is rejected at
# the TLS handshake fleet-wide the moment nginx reloads. This script does NOT touch the remote VPS: it
# only prints the reload hint; run the reload yourself on the box after copying the CRL.
#
# Requires the `openssl ca` database created by deploy/scripts/gen-device-ca.sh (index.txt/serial/…).
#
# Usage: revoke-device.sh <LEAF_CERT.pem> # revoke by the issued cert file
# or revoke-device.sh --serial <HEX_SERIAL> # revoke by serial (uses <CA_DIR>/newcerts/<serial>.pem)
#
# Config via ENV or flags (no secrets):
# CA_DIR which CA to revoke against (default /etc/relay/device-ca) — or --ca-dir <path>
#
# Verify (syntax): bash -n deploy/scripts/revoke-device.sh
set -euo pipefail
CA_DIR="${CA_DIR:-/etc/relay/device-ca}"
LEAF=""
SERIAL=""
while [[ $# -gt 0 ]]; do
case "$1" in
--ca-dir) CA_DIR="${2:?--ca-dir needs a value}"; shift 2 ;;
--serial) SERIAL="${2:?--serial needs a value}"; shift 2 ;;
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
-*) echo "FATAL: unknown flag '$1' (see --help)" >&2; exit 2 ;;
*) if [[ -z "${LEAF}" ]]; then LEAF="$1";
else echo "FATAL: too many positional args at '$1'" >&2; exit 2; fi; shift ;;
esac
done
command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not found on PATH." >&2; exit 3; }
[[ -f "${CA_DIR}/openssl.cnf" ]] || { echo "FATAL: no CA db in ${CA_DIR} (run gen-device-ca.sh first)." >&2; exit 3; }
# ---- resolve the target leaf (by path or by serial) to an absolute path --------------------------
if [[ -n "${SERIAL}" ]]; then
# Boundary-validate the serial (used to build a path); openssl stores newcerts/<UPPERCASE-HEX>.pem.
[[ "${SERIAL}" =~ ^[0-9A-Fa-f]+$ ]] || { echo "FATAL: --serial must be hex." >&2; exit 2; }
LEAF="${CA_DIR}/newcerts/$(echo "${SERIAL}" | tr '[:lower:]' '[:upper:]').pem"
fi
[[ -n "${LEAF}" ]] || { echo "FATAL: pass a <LEAF_CERT.pem> or --serial <HEX>." >&2; exit 2; }
[[ -f "${LEAF}" ]] || { echo "FATAL: leaf cert not found: ${LEAF}" >&2; exit 3; }
LEAF="$(cd "$(dirname "${LEAF}")" && pwd)/$(basename "${LEAF}")"
REVOKED_SERIAL="$(openssl x509 -in "${LEAF}" -noout -serial | cut -d= -f2)"
echo "== Revoking serial ${REVOKED_SERIAL} against CA ${CA_DIR} =="
# ---- revoke + regenerate the CRL (cd in so openssl.cnf's dir=. resolves to CA_DIR) ----------------
(
cd "${CA_DIR}"
openssl ca -config openssl.cnf -revoke "${LEAF}"
openssl ca -config openssl.cnf -gencrl -out crl.pem
)
chmod 644 "${CA_DIR}/crl.pem"
cat <<SUMMARY
Revoked serial ${REVOKED_SERIAL}. Updated CRL: ${CA_DIR}/crl.pem
Confirm: openssl crl -in ${CA_DIR}/crl.pem -noout -text | grep -A1 'Serial Number'
RELOAD HINT (run ON THE VPS after copying the new crl.pem into place — NOT executed here):
nginx -t && nginx -s reload
Until nginx reloads the CRL, the revoked cert is still accepted.
SUMMARY

View File

@@ -0,0 +1,52 @@
# RELAY-PHASE1 · E (TASK E) — control-plane (P3) systemd unit. STAGING TEMPLATE.
#
# Runs the P3 admin/registry/CA/pairing API (control-plane/src/server.ts, added in A2) on the
# loopback admin port (CP_BIND_HOST/CP_BIND_PORT in the env file). It is NEVER exposed publicly —
# the security group keeps 8080 loopback-only; operators reach it via SSH tunnel.
#
# INSTALL (adjust the placeholder paths to your VPS):
# 1) clone the repo to /opt/web-terminal (or edit --prefix below)
# 2) install: sudo cp deploy/systemd/relay-control-plane.service /etc/systemd/system/
# 3) put the filled env at /etc/relay/control-plane.env (0600, owned by the service user)
# 4) sudo systemctl daemon-reload && sudo systemctl enable --now relay-control-plane.service
#
# The env file supplies PG_URL/REDIS_URL/CAPABILITY_SIGN_PUBKEY_B64/CA_* /BASE_DOMAIN (see .env.example).
[Unit]
Description=web-terminal rendezvous-relay CONTROL PLANE (P3)
Documentation=file:///opt/web-terminal/docs/PLAN_RELAY_PHASE1.md
# Postgres + Redis run under Docker Compose (deploy/docker-compose.yml); wait for docker + network.
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
# Dedicated non-root service user (create once: sudo useradd --system --home /opt/web-terminal relay).
User=relay
Group=relay
WorkingDirectory=/opt/web-terminal
# Secrets/config come ONLY from the env file (INV9 — no secrets inline in the unit).
EnvironmentFile=/etc/relay/control-plane.env
# /usr/bin/env resolves npm via PATH (works for NodeSource /usr/bin/npm and nvm shims alike).
# Runs the "start" script (control-plane/package.json -> tsx src/server.ts).
ExecStart=/usr/bin/env npm --prefix /opt/web-terminal/control-plane start
Restart=always
RestartSec=3
# Give in-flight requests / graceful SIGTERM (server.ts closes the http listener + pg/redis) time.
TimeoutStopSec=20
KillSignal=SIGTERM
# --- hardening (INV9: shrink the blast radius around the CA + capability material) ---
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ProtectControlGroups=true
ProtectKernelTunables=true
RestrictSUIDSGID=true
# The CA/capability material under /etc/relay is READ-ONLY to this service.
ReadOnlyPaths=/etc/relay
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,53 @@
# RELAY-PHASE1 · E (TASK E) — relay data-plane (P1) systemd unit. STAGING TEMPLATE.
#
# Runs the Phase-1 relay entry (relay-run/src/main-phase1.ts via `start:phase1`, added in B5): browser
# WSS on :443 (BIND_PORT) and agent mTLS on AGENT_BIND_PORT. Stateless / restart-safe (INV7) — all
# state is in Postgres/Redis, so bouncing this unit does NOT drop host registrations or kill PTYs.
#
# INSTALL (adjust the placeholder paths to your VPS):
# 1) clone the repo to /opt/web-terminal (or edit --prefix below)
# 2) install: sudo cp deploy/systemd/relay-data-plane.service /etc/systemd/system/
# 3) put the filled env at /etc/relay/relay.env (0600, owned by the service user)
# 4) sudo systemctl daemon-reload && sudo systemctl enable --now relay-data-plane.service
#
# The env file supplies BIND_HOST/BIND_PORT/TLS_*/AGENT_BIND_PORT/AGENT_CA_*/BASE_DOMAIN/
# RELAY_NODE_ID/RELAY_AUTH_VERIFY_PUBKEY/RELAY_TRUST_DOMAIN/PG_URL/REDIS_URL (see .env.example).
[Unit]
Description=web-terminal rendezvous-relay DATA PLANE (P1, Phase 1)
Documentation=file:///opt/web-terminal/docs/PLAN_RELAY_PHASE1.md
# Reads the SAME Postgres/Redis as the control-plane (routes, revocations, registries).
After=network-online.target docker.service relay-control-plane.service
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
User=relay
Group=relay
WorkingDirectory=/opt/web-terminal
EnvironmentFile=/etc/relay/relay.env
# Runs relay-run "start:phase1" (relay-run/package.json -> tsx src/main-phase1.ts).
ExecStart=/usr/bin/env npm --prefix /opt/web-terminal/relay-run run start:phase1
Restart=always
RestartSec=3
TimeoutStopSec=20
KillSignal=SIGTERM
# Bind the privileged browser port :443 WITHOUT running as root (least privilege).
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
# --- hardening ---
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ProtectControlGroups=true
ProtectKernelTunables=true
RestrictSUIDSGID=true
# TLS keys + the pinned agent-CA bundle are read-only to the relay.
ReadOnlyPaths=/etc/relay
[Install]
WantedBy=multi-user.target

View File

@@ -11,7 +11,8 @@
*
* __dirname is available because esbuild emits this as a CJS bundle (build.mjs).
*/
import { app, BrowserWindow, Menu, Notification, Tray } from 'electron'
import { app, BrowserWindow, Menu, Notification, Tray, dialog } from 'electron'
import type { Certificate } from 'electron'
import path from 'node:path'
import { createLogger } from './logger.js'
@@ -20,11 +21,21 @@ import { startEmbeddedServer } from './embedded-server.js'
import { computeNotifications } from './notifications.js'
import { mapLiveSessions } from './live-poll.js'
import { parseDeepLink, deepLinkToPath, DEEP_LINK_PROTOCOL } from './deep-link.js'
import { createMainWindow } from './window.js'
import { createMainWindow, originOf } from './window.js'
import { buildAppMenu } from './menu.js'
import { createTray } from './tray.js'
import { createTray, buildTrayMenu } from './tray.js'
import type { TrayHandlers } from './tray.js'
import {
selectHost,
selectedRemoteHost,
remoteHostOrigin,
makeRemoteHost,
addRemoteHost,
removeRemoteHost,
TERMINAL_ZONE_SUFFIX,
} from './remote-hosts.js'
import type { NotifyOptions } from './notify-policy.js'
import type { EmbeddedServer, SessionStatusSnapshot } from './types.js'
import type { DesktopPrefs, EmbeddedServer, SessionStatusSnapshot } from './types.js'
/** Live-session poll cadence — cheap loopback GET, so a few seconds is plenty. */
const POLL_INTERVAL_MS = 4000
@@ -35,12 +46,75 @@ const PRELOAD_FILE = 'preload.cjs'
/** Tray icon location relative to the build/ output dir (shipped under assets/). */
const TRAY_ICON_SUBPATH = path.join('..', 'assets', 'trayTemplate.png')
/**
* DEVICE_CA_CN — Common Name of the device client-certificate CA (VPS Track V2,
* gen-device-ca.sh). The device `.p12` lives in the OS keychain (macOS login
* keychain / Windows Personal store); Chromium sources client certificates ONLY
* from the OS store — reading a `.p12` in-app is infeasible — so on an mTLS
* handshake we select the cert whose issuer CN matches this. Override for a
* differently-named CA via the WEBTERM_DEVICE_CA_CN env var.
*/
const DEVICE_CA_CN = process.env.WEBTERM_DEVICE_CA_CN ?? 'WebTerminal Device CA'
/** Add-remote-host prompt window dimensions. */
const PROMPT_WIDTH = 460
const PROMPT_HEIGHT = 260
/**
* Minimal self-contained HTML for the "Add remote host" prompt window. It runs no
* Node (sandboxed, no preload) and returns its result by setting `location.hash`
* (read back via did-navigate-in-page); user text is JSON+URI-encoded into the
* hash, never interpolated into markup, so there is no injection surface. The CSP
* allows only inline style/script from this trusted, app-authored document.
*/
const PROMPT_HTML = `<!doctype html>
<html><head><meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
<style>
:root { color-scheme: dark light; }
body { font: 13px -apple-system, Segoe UI, system-ui, sans-serif; margin: 0; padding: 18px;
background: #0e0f13; color: #e6e6e6; }
h1 { font-size: 15px; margin: 0 0 14px; }
label { display: block; margin-bottom: 10px; }
span { display: block; margin-bottom: 4px; color: #9aa0ab; }
input { width: 100%; box-sizing: border-box; padding: 7px 8px; border-radius: 6px;
border: 1px solid #333; background: #16181d; color: #e6e6e6; font: inherit; }
.row { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
button { padding: 7px 14px; border-radius: 6px; border: 1px solid #333;
background: #22252c; color: #e6e6e6; font: inherit; cursor: pointer; }
button[type=submit] { background: #2d6cdf; border-color: #2d6cdf; color: #fff; }
</style></head>
<body>
<h1>Add remote host</h1>
<form id="f">
<label><span>Name</span><input id="name" autofocus placeholder="Laptop"></label>
<label><span>URL</span><input id="url" value="https://" placeholder="https://t1.terminal.yaojia.wang"></label>
<div class="row">
<button type="button" id="cancel">Cancel</button>
<button type="submit">Add</button>
</div>
</form>
<script>
var form = document.getElementById('f');
form.addEventListener('submit', function (e) {
e.preventDefault();
var payload = { name: document.getElementById('name').value, url: document.getElementById('url').value };
location.hash = 'ok:' + encodeURIComponent(JSON.stringify(payload));
});
document.getElementById('cancel').addEventListener('click', function () { location.hash = 'cancel'; });
</script>
</body></html>`
const logger = createLogger('main')
const settings = createSettingsStore(app.getPath('userData'), process.platform, logger)
let mainWindow: BrowserWindow | null = null
let tray: Tray | null = null
let server: EmbeddedServer | null = null
// The origin the main window is currently locked to (local embedded server, or a
// selected remote tunnel host). Read live by window.ts's will-navigate guard;
// reassigned (never mutated) whenever we point the window at a different host.
let activeOrigin: string | null = null
let pollTimer: NodeJS.Timeout | null = null
let isQuitting = false
let isShuttingDown = false
@@ -66,6 +140,193 @@ function focusWindow(): void {
mainWindow.focus()
}
/** True iff `value` is a plain, indexable object (not null, not an array). */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** The embedded local server's base URL (`http://127.0.0.1:<port>`), or null. */
function localBaseUrl(): string | null {
return server ? `http://127.0.0.1:${server.port}` : null
}
/**
* Resolve the page URL + origin the window should currently be on, given prefs:
* the selected remote host, else the local embedded server. Returns null only if
* neither is resolvable (e.g. server not up yet and no valid remote selected).
*/
function resolveTarget(prefs: DesktopPrefs): { url: string; origin: string } | null {
const remote = selectedRemoteHost(prefs)
if (remote) {
const origin = remoteHostOrigin(remote)
if (origin !== null) return { url: remote.url, origin }
}
const base = localBaseUrl()
if (base === null) return null
return { url: `${base}/`, origin: base }
}
/** Rebuild the tray context menu from the current host list + selection. */
function refreshTray(): void {
if (!tray) return
const prefs = settings.get()
tray.setContextMenu(
buildTrayMenu(
{ remoteHosts: prefs.remoteHosts, selectedHostId: prefs.selectedHostId },
trayHandlers,
),
)
}
/**
* Switch the window to a host (null → local). Persists the selection, updates the
* origin lock, navigates the window (programmatic loadURL does NOT trip
* will-navigate), refreshes the tray, and focuses. A no-op if nothing resolves.
*/
function switchToHost(id: string | null): void {
const prefs = settings.set(selectHost(settings.get(), id))
const target = resolveTarget(prefs)
if (!target) {
logger.warn('cannot switch host: no resolvable target (server not ready?)')
return
}
activeOrigin = target.origin
if (mainWindow) void mainWindow.loadURL(target.url)
refreshTray()
focusWindow()
}
/** Prompt for a new remote host, validate it, persist it, and switch to it. */
async function addRemoteHostFlow(): Promise<void> {
const input = await promptRemoteHost()
if (!input) return
const host = makeRemoteHost(input.name, input.url)
if (!host) {
showError(
'Invalid remote host',
'That is not a valid tunnel URL.',
`Enter an https URL under ${TERMINAL_ZONE_SUFFIX}, ` +
'e.g. https://t1.terminal.yaojia.wang',
)
return
}
settings.set(addRemoteHost(settings.get(), host))
switchToHost(host.id)
}
/** Remove a remote host; if it was selected, selection falls back to local. */
function removeRemoteHostFlow(id: string): void {
const prefs = settings.set(removeRemoteHost(settings.get(), id))
const target = resolveTarget(prefs)
if (target) {
activeOrigin = target.origin
if (mainWindow) void mainWindow.loadURL(target.url)
}
refreshTray()
}
/** Handlers wired into the tray host-picker (stable object; reads prefs live). */
const trayHandlers: TrayHandlers = {
show: () => focusWindow(),
quit: () => {
isQuitting = true
app.quit()
},
selectHost: (id) => switchToHost(id),
addHost: () => {
void addRemoteHostFlow()
},
removeHost: (id) => removeRemoteHostFlow(id),
}
/** Show a native error dialog (parented to the main window when one exists). */
function showError(title: string, message: string, detail: string): void {
const options = { type: 'error' as const, title, message, detail }
if (mainWindow) void dialog.showMessageBox(mainWindow, options)
else void dialog.showMessageBox(options)
}
/**
* Modal prompt for a remote-host name + URL. Resolves to the entered values, or
* null if cancelled/closed. The window returns its result via location.hash (no
* Node, no preload, no IPC surface); see PROMPT_HTML.
*/
function promptRemoteHost(): Promise<{ name: string; url: string } | null> {
return new Promise((resolve) => {
const win = new BrowserWindow({
parent: mainWindow ?? undefined,
modal: mainWindow !== null,
width: PROMPT_WIDTH,
height: PROMPT_HEIGHT,
resizable: false,
minimizable: false,
maximizable: false,
title: 'Add remote host',
backgroundColor: '#0e0f13',
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
})
let settled = false
const finish = (result: { name: string; url: string } | null): void => {
if (settled) return
settled = true
resolve(result)
if (!win.isDestroyed()) win.close()
}
win.webContents.on('did-navigate-in-page', (_event, navUrl) => {
let hash: string
try {
hash = new URL(navUrl).hash
} catch {
return
}
if (hash === '#cancel') {
finish(null)
return
}
if (!hash.startsWith('#ok:')) return
try {
const parsed: unknown = JSON.parse(decodeURIComponent(hash.slice('#ok:'.length)))
if (isRecord(parsed) && typeof parsed['name'] === 'string' && typeof parsed['url'] === 'string') {
finish({ name: parsed['name'], url: parsed['url'] })
} else {
finish(null)
}
} catch {
finish(null)
}
})
win.on('closed', () => finish(null))
void win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(PROMPT_HTML)}`)
})
}
/**
* D-2: choose the client certificate whose issuer is our device CA. The OS
* keychain may hold unrelated certs, so match on issuer CN rather than picking
* blindly. Returns null when nothing in the list was issued by DEVICE_CA_CN.
*/
function pickCertByIssuer(list: readonly Certificate[], issuerCn: string): Certificate | null {
return (
list.find(
(cert) => cert.issuerName === issuerCn || cert.issuer.commonName === issuerCn,
) ?? null
)
}
/** Tell the user their device cert isn't installed (D-2: never fail silently). */
function showDeviceCertMissingDialog(requestUrl: string): void {
const host = originOf(requestUrl) ?? requestUrl
showError(
'Device certificate not installed',
'No matching device certificate was found in the OS keychain.',
`Connecting to ${host} requires your device client certificate (issued by ` +
`"${DEVICE_CA_CN}"). Install the provided device.p12 into the login keychain ` +
'(macOS: double-click the file) or the Personal certificate store (Windows), ' +
'then reconnect. On macOS you will be prompted once to "Always Allow" access ' +
'to the private key.',
)
}
/** Find the first terminalapp:// argument in a process argv list. */
function findDeepLinkArg(argv: readonly string[]): string | null {
const prefix = `${DEEP_LINK_PROTOCOL}://`
@@ -90,9 +351,13 @@ function dispatchDeepLink(url: string): void {
}
const targetPath = deepLinkToPath(link)
const base = `http://127.0.0.1:${server.port}`
// A deep link joins a LOCAL session, so navigate the window back to the local
// origin and move the lock with it (in case a remote host was active).
activeOrigin = base
focusWindow()
mainWindow.webContents.send(DEEP_LINK_CHANNEL, targetPath)
void mainWindow.loadURL(`http://127.0.0.1:${server.port}${targetPath}`)
void mainWindow.loadURL(`${base}${targetPath}`)
}
/** Hide-to-tray on window close (keep the server alive) unless we're quitting. */
@@ -154,18 +419,23 @@ async function onReady(): Promise<void> {
server = await startEmbeddedServer({ prefs, logger })
const base = `http://127.0.0.1:${server.port}`
mainWindow = createMainWindow(`${base}/`, path.join(__dirname, PRELOAD_FILE))
// Honor a previously-selected remote host across restarts; else land on local.
const initial = resolveTarget(prefs) ?? { url: `${base}/`, origin: base }
activeOrigin = initial.origin
mainWindow = createMainWindow(
initial.url,
path.join(__dirname, PRELOAD_FILE),
() => activeOrigin,
)
installWindowCloseGuard(mainWindow)
Menu.setApplicationMenu(buildAppMenu())
try {
tray = createTray(path.join(__dirname, TRAY_ICON_SUBPATH), {
show: () => focusWindow(),
quit: () => {
isQuitting = true
app.quit()
},
})
tray = createTray(
path.join(__dirname, TRAY_ICON_SUBPATH),
{ remoteHosts: prefs.remoteHosts, selectedHostId: prefs.selectedHostId },
trayHandlers,
)
} catch (err: unknown) {
// A missing/invalid tray icon (cosmetic) must not take down a working app.
logger.warn(`tray unavailable (continuing without it): ${errorMessage(err)}`)
@@ -216,6 +486,34 @@ if (!app.requestSingleInstanceLock()) {
} else {
app.setAsDefaultProtocolClient(DEEP_LINK_PROTOCOL)
// ── D-2: mTLS device-certificate selection (from the OS keychain) ──────────
// When a remote tunnel host (nginx `ssl_verify_client on`) requests a client
// cert, Chromium asks us which of the OS-store certs to present. preventDefault
// is MANDATORY: without it Electron auto-picks list[0] (any cert, no control).
// We deliberately choose the one issued by our device CA; if none is installed
// we show a clear dialog (never a silent callback) and proceed cert-less so the
// TLS handshake fails cleanly rather than hanging.
app.on('select-client-certificate', (event, _webContents, url, list, callback) => {
event.preventDefault()
const cert = pickCertByIssuer(list, DEVICE_CA_CN)
if (!cert) {
showDeviceCertMissingDialog(url)
callback() // no cert → nginx rejects the handshake (clean, non-hanging failure)
return
}
callback(cert)
})
// ── D-3 (deferred — documented TODO; coordinate with Track V/S) ────────────
// This box can also be its OWN tunnel source. A follow-up may optionally spawn
// + supervise a local `frpc` from here (PLAN_NATIVE_TUNNEL §"C-Desktop / D-3")
// and inject the embedded server's ALLOWED_ORIGINS=
// https://<name>.terminal.yaojia.wang (additive via the backend config.ts). The
// embedded server already defaults BIND_HOST=127.0.0.1 (server-config.ts), which
// satisfies precondition P-A — EXCEPT when `lanSharing` is enabled: that binds
// 0.0.0.0 and re-opens an unauthenticated shell on the LAN, bypassing mTLS. So a
// tunnel-source host must keep lanSharing OFF. Not implemented here.
app.on('second-instance', (_event, argv) => {
focusWindow()
const url = findDeepLinkArg(argv)

View File

@@ -12,7 +12,8 @@
* CRITICAL rule); they never mutate their arguments.
*/
import type { DesktopPrefs } from './types.js'
import type { DesktopPrefs, RemoteHost } from './types.js'
import { normalizeRemoteUrl } from './remote-hosts.js'
/** Valid TCP port range (inclusive); mirrors src/config.ts's PORT bounds. */
const MIN_PORT = 1
@@ -36,6 +37,8 @@ export function defaultPrefs(platform: NodeJS.Platform): DesktopPrefs {
openAtLogin: false,
notifyOnApproval: true,
notifyOnStatusChange: true,
remoteHosts: [],
selectedHostId: null,
}
}
@@ -70,6 +73,51 @@ function pickShellPath(value: unknown, fallback: string | null): string | null {
return fallback
}
/**
* Validate one untrusted remote-host record. Requires string id/name and a URL
* that survives normalization (https + tunnel zone); the stored URL is replaced
* with its canonical form. Returns null for anything malformed, so a corrupt or
* hand-edited entry is dropped rather than trusted downstream.
*/
function pickRemoteHost(value: unknown): RemoteHost | null {
if (!isRecord(value)) return null
const { id, name, url } = value
if (typeof id !== 'string' || id === '') return null
if (typeof name !== 'string' || name.trim() === '') return null
if (typeof url !== 'string') return null
const normalizedUrl = normalizeRemoteUrl(url)
if (normalizedUrl === null) return null
return { id, name: name.trim(), url: normalizedUrl }
}
/**
* Adopt a list of remote hosts from untrusted input: keep only records that
* validate, and drop duplicate ids (first wins) so lookups are unambiguous. A
* non-array yields an empty list.
*/
function pickRemoteHosts(value: unknown): readonly RemoteHost[] {
if (!Array.isArray(value)) return []
const seen = new Set<string>()
const hosts: RemoteHost[] = []
for (const entry of value) {
const host = pickRemoteHost(entry)
if (host === null || seen.has(host.id)) continue
seen.add(host.id)
hosts.push(host)
}
return hosts
}
/**
* Adopt the selected host id: null (local), or a string that refers to a host
* actually present in the validated list. A dangling id degrades to null so we
* never point selection at a host that was dropped or never existed.
*/
function pickSelectedHostId(value: unknown, hosts: readonly RemoteHost[]): string | null {
if (typeof value !== 'string') return null
return hosts.some((host) => host.id === value) ? value : null
}
/**
* Re-derive a fully-formed DesktopPrefs from untrusted parsed-JSON input.
* Starts from defaults and adopts each field only when it is the right type and
@@ -78,6 +126,7 @@ function pickShellPath(value: unknown, fallback: string | null): string | null {
export function validatePrefs(raw: unknown, platform: NodeJS.Platform): DesktopPrefs {
const defaults = defaultPrefs(platform)
if (!isRecord(raw)) return defaults
const remoteHosts = pickRemoteHosts(raw['remoteHosts'])
return {
port: pickPort(raw['port'], defaults.port),
lanSharing: pickBoolean(raw['lanSharing'], defaults.lanSharing),
@@ -85,6 +134,8 @@ export function validatePrefs(raw: unknown, platform: NodeJS.Platform): DesktopP
openAtLogin: pickBoolean(raw['openAtLogin'], defaults.openAtLogin),
notifyOnApproval: pickBoolean(raw['notifyOnApproval'], defaults.notifyOnApproval),
notifyOnStatusChange: pickBoolean(raw['notifyOnStatusChange'], defaults.notifyOnStatusChange),
remoteHosts,
selectedHostId: pickSelectedHostId(raw['selectedHostId'], remoteHosts),
}
}

125
desktop/src/remote-hosts.ts Normal file
View File

@@ -0,0 +1,125 @@
/**
* desktop/src/remote-hosts.ts — D-1: remote-host mode (PLAN_NATIVE_TUNNEL,
* Track C-Desktop). Pure, Electron-free helpers over the RemoteHost list held in
* DesktopPrefs, plus the URL validation that guards what the BrowserWindow is
* allowed to load.
*
* A "remote host" is one of the user's own machines exposed through the mTLS
* reverse tunnel at `https://<name>.terminal.yaojia.wang/`. The desktop app can
* switch the main window between "This machine (local)" (the embedded loopback
* server, which keeps running) and any configured remote host; the device client
* certificate from the OS keychain (see main.ts / D-2) is the only auth gate.
*
* Every function is PURE and returns NEW objects (immutability — coding-style
* CRITICAL rule); none mutate their arguments. No `electron` import, so this file
* is unit-testable in plain Node.
*/
import { randomUUID } from 'node:crypto'
import type { DesktopPrefs, RemoteHost } from './types.js'
/**
* The tunnel DNS zone (PLAN_NATIVE_TUNNEL: wildcard `*.terminal.yaojia.wang`).
* Overridable so a differently-named deployment isn't hard-blocked. A remote host
* URL must be https (mixed-content + mTLS both require TLS) and, by default, live
* under this zone — a boundary sanity check, not the security boundary itself
* (that is mTLS at nginx + the will-navigate origin lock).
*/
export const TERMINAL_ZONE_SUFFIX = process.env.WEBTERM_TUNNEL_ZONE ?? '.terminal.yaojia.wang'
/** Parse an origin, returning null for anything malformed. */
function originOf(url: string): string | null {
try {
return new URL(url).origin
} catch {
return null
}
}
/**
* Validate + normalize a user-entered remote-host URL. Returns the canonical
* `https://<host>/` form, or null when the input is not a usable https tunnel URL
* (unparseable, non-https, or — by default — outside the tunnel zone). Validation
* at the boundary: everything downstream may trust the returned string.
*/
export function normalizeRemoteUrl(raw: string): string | null {
let parsed: URL
try {
parsed = new URL(raw.trim())
} catch {
return null
}
if (parsed.protocol !== 'https:') return null
if (parsed.hostname === '') return null
if (TERMINAL_ZONE_SUFFIX !== '' && !parsed.hostname.endsWith(TERMINAL_ZONE_SUFFIX)) {
return null
}
// Canonical form: origin + trailing slash (path/query/hash stripped — the
// frontend is served from the root, and the origin is all the lock compares).
return `${parsed.origin}/`
}
/**
* Build a RemoteHost from user input, or null if the name is blank or the URL is
* not a valid https tunnel URL. The id is a fresh uuid (stable across renames).
*/
export function makeRemoteHost(name: string, url: string): RemoteHost | null {
const trimmedName = name.trim()
const normalizedUrl = normalizeRemoteUrl(url)
if (trimmedName === '' || normalizedUrl === null) return null
return { id: randomUUID(), name: trimmedName, url: normalizedUrl }
}
/** Look up a configured host by id. */
export function findRemoteHost(
prefs: Readonly<DesktopPrefs>,
id: string,
): RemoteHost | undefined {
return prefs.remoteHosts.find((host) => host.id === id)
}
/**
* Immutably append a host, de-duplicating by URL so re-adding the same machine
* replaces the old entry (and its name) rather than piling up duplicates.
*/
export function addRemoteHost(
prefs: Readonly<DesktopPrefs>,
host: RemoteHost,
): DesktopPrefs {
const withoutDup = prefs.remoteHosts.filter((existing) => existing.url !== host.url)
return { ...prefs, remoteHosts: [...withoutDup, host] }
}
/**
* Immutably remove a host by id. If the removed host was selected, selection
* falls back to local (selectedHostId → null) so we never point at a gone host.
*/
export function removeRemoteHost(prefs: Readonly<DesktopPrefs>, id: string): DesktopPrefs {
const remoteHosts = prefs.remoteHosts.filter((host) => host.id !== id)
const selectedHostId = prefs.selectedHostId === id ? null : prefs.selectedHostId
return { ...prefs, remoteHosts, selectedHostId }
}
/**
* Immutably select a host (null → local). Selecting an unknown id is a no-op —
* the caller stays on whatever was selected, never a dangling reference.
*/
export function selectHost(prefs: Readonly<DesktopPrefs>, id: string | null): DesktopPrefs {
if (id === null) return { ...prefs, selectedHostId: null }
if (findRemoteHost(prefs, id) === undefined) return { ...prefs }
return { ...prefs, selectedHostId: id }
}
/**
* The currently-selected remote host, or null when local is selected (either
* selectedHostId is null, or it dangles at a host that no longer exists).
*/
export function selectedRemoteHost(prefs: Readonly<DesktopPrefs>): RemoteHost | null {
if (prefs.selectedHostId === null) return null
return findRemoteHost(prefs, prefs.selectedHostId) ?? null
}
/** The origin a remote host's window should be locked to, or null if malformed. */
export function remoteHostOrigin(host: Readonly<RemoteHost>): string | null {
return originOf(host.url)
}

View File

@@ -3,30 +3,92 @@
* after its window is hidden (the embedded server must keep running so remote
* devices stay connected — the vibe-coding scenario).
*
* The context menu also hosts the D-1 host-picker (PLAN_NATIVE_TUNNEL /
* C-Desktop): "This machine (local)" ↔ each configured remote tunnel host, as a
* radio group, plus add/remove. The menu is rebuilt (never mutated) whenever the
* host list or selection changes — call buildTrayMenu() and setContextMenu().
*
* Icon requirement: `iconPath` MUST point at an existing image. new Tray() with a
* missing/invalid path can throw on some platforms; that failure surfaces to the
* caller rather than being swallowed (a broken install should be visible, not
* silently degrade). The caller owns whether to guard startup around it.
*/
import { Menu, Tray } from 'electron'
import type { MenuItemConstructorOptions } from 'electron'
import type { RemoteHost } from './types.js'
const TRAY_TOOLTIP = 'Web Terminal'
/** Label for the local (embedded server) entry — selectedHostId === null. */
const LOCAL_HOST_LABEL = 'This machine (local)'
export interface TrayHandlers {
show(): void
quit(): void
/** Switch the active host; null → "This machine (local)". */
selectHost(id: string | null): void
/** Prompt for + add a new remote host. */
addHost(): void
/** Remove a configured remote host by id. */
removeHost(id: string): void
}
export function createTray(iconPath: string, handlers: TrayHandlers): Tray {
const tray = new Tray(iconPath)
/** The host-picker state the menu renders (the local entry is implicit). */
export interface TrayHostState {
readonly remoteHosts: readonly RemoteHost[]
/** Selected host id, or null → local. */
readonly selectedHostId: string | null
}
const menu = Menu.buildFromTemplate([
{ label: 'Show Web Terminal', click: handlers.show },
/** Build the tray context menu for the given host state (pure construction). */
export function buildTrayMenu(state: TrayHostState, handlers: TrayHandlers): Menu {
const localItem: MenuItemConstructorOptions = {
label: LOCAL_HOST_LABEL,
type: 'radio',
checked: state.selectedHostId === null,
click: () => handlers.selectHost(null),
}
const hostItems: MenuItemConstructorOptions[] = state.remoteHosts.map((host) => ({
label: host.name,
type: 'radio',
checked: state.selectedHostId === host.id,
click: () => handlers.selectHost(host.id),
}))
const selectedRemoteId =
state.selectedHostId !== null &&
state.remoteHosts.some((host) => host.id === state.selectedHostId)
? state.selectedHostId
: null
const template: MenuItemConstructorOptions[] = [
{ label: 'Host', enabled: false },
localItem,
...hostItems,
{ type: 'separator' },
{ label: 'Quit', click: handlers.quit },
])
{ label: 'Add remote host…', click: () => handlers.addHost() },
{
label: 'Remove selected host',
enabled: selectedRemoteId !== null,
click: () => {
if (selectedRemoteId !== null) handlers.removeHost(selectedRemoteId)
},
},
{ type: 'separator' },
{ label: 'Show Web Terminal', click: () => handlers.show() },
{ label: 'Quit', click: () => handlers.quit() },
]
return Menu.buildFromTemplate(template)
}
export function createTray(
iconPath: string,
state: TrayHostState,
handlers: TrayHandlers,
): Tray {
const tray = new Tray(iconPath)
tray.setToolTip(TRAY_TOOLTIP)
tray.setContextMenu(menu)
tray.setContextMenu(buildTrayMenu(state, handlers))
return tray
}

View File

@@ -21,6 +21,22 @@ export type DesktopClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown' | '
/** Approval gate kind, mirrored from backend PermissionGate (src/types.ts:101). */
export type DesktopPermissionGate = 'tool' | 'plan'
/**
* A remote web-terminal host reachable over the mTLS reverse tunnel
* (Track C-Desktop, PLAN_NATIVE_TUNNEL §"C-Desktop / D-1"). `url` is the https
* origin the BrowserWindow loads, e.g. `https://t1.terminal.yaojia.wang/`; the
* device client certificate (D-2, sourced from the OS keychain) is the only auth
* gate. `id` is a stable opaque handle used by the tray host-picker + prefs.
*/
export interface RemoteHost {
/** Stable opaque id (uuid); the tray/prefs key, never shown to the user. */
readonly id: string
/** Human label shown in the tray host-picker. */
readonly name: string
/** The https origin URL to load, normalized to `https://<host>/`. */
readonly url: string
}
/**
* User preferences, persisted as JSON in the app's userData dir (hand-rolled
* store — matches the project's minimal-deps convention; no electron-store).
@@ -38,6 +54,10 @@ export interface DesktopPrefs {
readonly notifyOnApproval: boolean
/** Fire a native notification when a session's Claude status changes. */
readonly notifyOnStatusChange: boolean
/** Configured remote tunnel hosts (D-1); empty on a fresh install. */
readonly remoteHosts: readonly RemoteHost[]
/** Selected host id, or null → "This machine (local)" (the embedded server). */
readonly selectedHostId: string | null
}
/** Handle returned by the embedded server, for lifecycle + window wiring. */

View File

@@ -1,12 +1,19 @@
/**
* desktop/src/window.ts — creates the single BrowserWindow that hosts the
* unchanged web frontend, loaded from the embedded localhost server.
* unchanged web frontend, loaded from either the embedded localhost server or a
* selected remote tunnel host (D-1, PLAN_NATIVE_TUNNEL / C-Desktop).
*
* Hardening (DESKTOP_PLAN §8 / TECH_DOC §7): contextIsolation on, nodeIntegration
* off, sandbox on, preload restricted to a minimal contextBridge. Because the
* only page ever loaded is the trusted embedded http://127.0.0.1:<port> origin,
* we deny every new-window request and block navigation to any foreign origin
* a defence-in-depth guard against a hijacked page trying to escape localhost.
* off, sandbox on, preload restricted to a minimal contextBridge. We deny every
* new-window request and block navigation to any foreign origin — a defence-in-
* depth guard against a hijacked page trying to escape the active origin.
*
* D-1 note: the origin lock is DYNAMIC. The window may be pointed (by main.ts,
* via a programmatic loadURL — which does NOT fire will-navigate) at exactly one
* host at a time: the embedded `http://127.0.0.1:<port>` origin OR the selected
* remote `https://<name>.terminal.yaojia.wang` origin. `getAllowedOrigin` returns
* whichever is active NOW, so renderer-initiated navigation stays locked to that
* single origin and everything else is still blocked.
*/
import { BrowserWindow } from 'electron'
@@ -15,7 +22,7 @@ const WINDOW_HEIGHT = 720
const BACKGROUND_COLOR = '#0e0f13'
/** Parse the origin of a URL, returning null for anything malformed. */
function originOf(url: string): string | null {
export function originOf(url: string): string | null {
try {
return new URL(url).origin
} catch {
@@ -23,7 +30,16 @@ function originOf(url: string): string | null {
}
}
export function createMainWindow(url: string, preloadPath: string): BrowserWindow {
/**
* Create the main window. `url` is the initial page to load; `getAllowedOrigin`
* is queried live on every navigation attempt and must return the origin the
* window is currently allowed on (or null to allow nothing / fail closed).
*/
export function createMainWindow(
url: string,
preloadPath: string,
getAllowedOrigin: () => string | null,
): BrowserWindow {
const win = new BrowserWindow({
width: WINDOW_WIDTH,
height: WINDOW_HEIGHT,
@@ -36,15 +52,14 @@ export function createMainWindow(url: string, preloadPath: string): BrowserWindo
},
})
const allowedOrigin = originOf(url)
// Never spawn child windows; the frontend has no legitimate reason to.
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
// Block navigation away from the embedded localhost origin.
// Block navigation away from the currently-active (local or remote) origin.
win.webContents.on('will-navigate', (event, targetUrl) => {
if (allowedOrigin === null) return
if (originOf(targetUrl) !== allowedOrigin) {
const allowedOrigin = getAllowedOrigin()
// Fail closed: if we can't determine the active origin, allow nothing.
if (allowedOrigin === null || originOf(targetUrl) !== allowedOrigin) {
event.preventDefault()
}
})

341
docs/PLAN_NATIVE_TUNNEL.md Normal file
View File

@@ -0,0 +1,341 @@
# Native mTLS Reverse-Tunnel — Master Development Plan
> **Destination:** `docs/PLAN_NATIVE_TUNNEL.md`
> **Status:** planning (synthesized from three reviewed track-plans: VPS, Server, Clients)
> **Deployed baseline:** VPS `8.138.1.192` (Ubuntu 24.04, nginx 1.24, `:443` shared via `ssl_preread` stream — existing `*.term.yaojia.wang`→relay:8443 and `default`→xray:10443 preserved verbatim). Wildcard DNS `*.terminal.yaojia.wang → 8.138.1.192` is confirmed resolving.
## 1. Overview
We are building a **multi-tenant reverse tunnel** so native iOS / Android / desktop clients can reach a **base web-terminal server** running on the user's *local* machine(s) from the public internet, relayed through the user's VPS. The base app speaks its own WS protocol (`attach`/`attached`, `/live-sessions`) — **not** the E2E relay — so the already-deployed browser relay cannot serve these clients; this is a parallel, independent ingress.
The tool is **frp**. Each local host runs `frpc`, which dials the VPS on `:443` and exposes its loopback base app (`127.0.0.1:3000`) as `<name>.terminal.yaojia.wang`. On the VPS everything multiplexes onto the single open port `:443`, SNI-routed by the existing nginx `stream` (`ssl_preread`, TLS passthrough), coexisting with the relay and xray-Reality.
**The base app has no login/auth of its own.** Therefore **mTLS client-certificate authentication is the one and only security gate**, and it must be airtight. Two SNI routes are added:
- **`frp.terminal.yaojia.wang``frps` control `:7000`** (TLS passthrough). Guarded by frps **control-channel mTLS** (a per-host *frp-client CA*) **plus** a token — a leaked token alone must not let an attacker register a subdomain.
- **`*.terminal.yaojia.wang` → nginx `:8470`** — a loopback TLS-terminating server that presents a publicly-trusted **LE wildcard cert**, enforces **`ssl_verify_client on`** against a **device CA** (with a CRL), then reverse-proxies (WS-upgrade) to the frps vhost `:7080``frpc` → local base app.
```
Public internet (only :443 open in Aliyun SG)
┌───────────────────────────────────┐
│ nginx stream :443 ssl_preread │ (TLS passthrough, no term)
│ map $ssl_preread_server_name → │
└───────────────────────────────────┘
┌───────────────┬───────────────────┬────────────────┬─────────────┐
│ frp.terminal │ *.terminal │ *.term │ default │
│ .yaojia.wang │ .yaojia.wang │ .yaojia.wang │ (fallthru) │
▼ ▼ ▼ ▼
127.0.0.1:7000 127.0.0.1:8470 127.0.0.1:8443 127.0.0.1:10443
┌──────────┐ ┌───────────────┐ ┌──────────┐ ┌──────────┐
│ frps │ │ nginx :8470 │ │ E2E relay│ │ xray │ ← EXISTING,
│ control │ │ TLS-term + │ │ (browser)│ │ Reality │ preserved
│ mTLS+tok │ │ mTLS device-CA│ └──────────┘ └──────────┘ verbatim
│ :7000 │ │ + CRL │
└────┬─────┘ │ proxy→ :7080 │
control │ └──────┬────────┘
channel │ │ vhost HTTP (WS upgrade)
│ ▼
│ ┌──────────────┐
└──────────►│ frps vhost │ routes by Host → subdomain
│ :7080 │
└──────┬───────┘
│ frp tunnel (frpc dials OUT :443, frp-TLS)
══════════════ user's LOCAL machine ══════════════
┌──────────────┐
│ frpc │ subdomain=<name>, localPort=3000
└──────┬───────┘
┌──────────────┐
│ base app │ BIND_HOST=127.0.0.1 :3000 (loopback ONLY)
│ ALLOWED_ORIGINS = https://<name>.terminal.yaojia.wang
└──────────────┘
native client ────────┘ iOS / Android / desktop present a DEVICE CERT
https://<name>.terminal.yaojia.wang (mTLS) → speak base WS /term + /live-sessions
```
**Trust model (declare in writing before shipping — this changes what "airtight" means):**
- **Model A — single-owner fleet** (matches the GOAL: "the user's LOCAL machine(s)"). Every device cert and host belongs to one operator. A single shared device-CA = "the closed set of my devices"; cross-host reach between your own machines is not a violation. **This is the v1 target.**
- **Model B — true multi-tenant** (distrusting third parties enroll). A single shared device-CA gives **zero cross-tenant isolation** (any valid cert reaches any subdomain). Model B is **out of scope for v1** and must not ship on the shared-CA design; §5-R3 records the mitigation path.
---
## 2. The three tracks
### Track V — VPS (`8.138.1.192`)
Coexistence rule for every phase: never retype the existing `map` lines; merge additively; deploy only via `nginx -t && systemctl reload nginx`; re-run the coexistence oracle after each phase.
**V0 — Preflight & snapshot (~20 min)**
- [ ] `cp /etc/nginx/stream-relay.conf{,.bak.$(date +%s)}`; `nginx -T > /root/nginx-dump.pre.txt`; `ss -ltnp | sort > /root/ports.pre.txt`.
- [ ] Capture the **exact** current `map` body (`hostnames; *.term.yaojia.wang→8443; default→10443`) and `nginx -v` (expect 1.24 → no standalone `http2` directive is legal).
- [ ] `dig +short {foo,frp}.terminal.yaojia.wang``8.138.1.192`.
- [ ] Capture "before" oracle: relay 200 + xray real-MS cert (`s_client`/`curl` probes).
- **Verify:** all reads succeed, `.bak` exists. **Risk:** none.
**V1 — frps: control `:7000` (mTLS) + vhost `:7080`, dedicated user, systemd (~1h)**
- [ ] Install `frps` ≥ v0.52 (`disableCustomTLSFirstByte` exists ≥ v0.50). Mainland fetch may be blocked → `scp` from laptop.
- [ ] Dedicated unprivileged `frp` user (loopback ports need no root) + systemd hardening (`NoNewPrivileges`, `ProtectSystem=strict`, `ReadWritePaths=/var/log/frp`).
- [ ] `/etc/relay/frp/frps.toml` (0600, `frp:frp`):
```toml
bindAddr = "127.0.0.1"
bindPort = 7000
vhostHTTPPort = 7080
auth.method = "token"
auth.token = "<FRP_TOKEN>" # openssl rand -hex 32 → /etc/relay/secrets.env
subDomainHost = "terminal.yaojia.wang"
transport.tls.force = true # control-channel mTLS — token is NOT the sole gate
transport.tls.certFile = "/etc/relay/frp-tls/frps-ctrl.cert.pem"
transport.tls.keyFile = "/etc/relay/frp-tls/frps-ctrl.key.pem"
transport.tls.trustedCaFile = "/etc/relay/frp-client-ca/frp-client-ca.cert.pem"
log.to = "/var/log/frp/frps.log"
log.level = "info"
```
- **Verify:** both ports loopback-bound; `journalctl -u frps` clean; `curl -s -H 'Host: probe.terminal.yaojia.wang' http://127.0.0.1:7080/` → frp 404 "no proxy". **Risk:** binary fetch (scp); token/cert never committed.
**V1b — Registration-authorization decision (~30 min1h) — closes the subdomain-takeover hole**
- [ ] **Model A (v1):** control-channel mTLS from V1 is the accepted gate. Issue **frp-client certs per host** (revoke one host by rotating the `frp-client-ca` trust without touching device certs). Document: the frp-client cert + token is a trusted secret whose leak compromises all hosts.
- [ ] **Model B (deferred):** add an frps `[[httpPlugins]]` NewProxy authz delegate (reuse `term-relay/frp-scaffold/plugin-hook.ts`) mapping client-cert CN → allowed subdomain, rejecting mismatches.
- **Verify:** an `frpc` with a valid token but **no** frp-client cert is refused at the control handshake; (Model B) host-A's cert claiming `subdomain=hostB` is refused.
**V2 — Device client-CA + issuance + revocation (~2h)**
- [ ] `deploy/scripts/gen-device-ca.sh`: EC **P-256** self-signed CA (`CA:TRUE`, `keyCertSign,cRLSign`, 3650d) → `/etc/relay/device-ca/` (0700), idempotent, separate trust root from agent-CA/web-PKI. Parametrize to also scaffold `frp-client-ca` for V1.
- [ ] `deploy/scripts/issue-device-cert.sh`: P-256 leaf, `EKU=clientAuth`, 825d; emits `.p12` (`openssl pkcs12 -export -legacy` for iOS/Android) **and** `.pem`/`.key.pem`/`.fullchain.pem` (desktop/curl). Appends CN/serial to `issued.log`.
- [ ] `deploy/scripts/revoke-device.sh`: `openssl ca` revoke → regenerate `/etc/relay/device-ca/crl.pem` → `nginx -s reload`. **Initialize an empty CRL now** so V4 can reference it.
- **Verify:** `openssl verify -CAfile device-ca.cert.pem test-device.pem` → OK; p12 lists the leaf; revoke → CRL lists the serial. **Risk:** p12 import quirks (mitigate `-legacy`); doc "P-256, never Ed25519".
**V3 — Wildcard server cert via LE DNS-01 (~1h) — LE is mainline (hard iOS prerequisite)**
- [ ] `acme.sh --dns dns_ali -d '*.terminal.yaojia.wang' -d terminal.yaojia.wang` → install `/etc/relay/frp-tls/fullchain.pem` + `privkey.pem` (0600), `--reloadcmd 'nginx -s reload'`. Needs `Ali_Key`/`Ali_Secret`. Separate cert from `*.term` (different parent label — do not try to cover both).
- **Verify:** SAN shows `*.terminal.yaojia.wang`; `curl https://<name>.terminal.yaojia.wang` from a stock machine validates with **no `-k`**. **Risk:** Aliyun DNS API creds/propagation — if unavailable, iOS bring-up stalls (self-signed fails on iOS).
**V4 — nginx `:8470` = TLS-term + mTLS + WS proxy (~1h)**
- [ ] `/etc/nginx/conf.d/frp-mtls.conf`:
```nginx
map $http_upgrade $connection_upgrade { default upgrade; '' close; }
server {
listen 127.0.0.1:8470 ssl; # NO `http2 off;` — invalid on nginx 1.24
server_name ~^.+\.terminal\.yaojia\.wang$;
ssl_certificate /etc/relay/frp-tls/fullchain.pem;
ssl_certificate_key /etc/relay/frp-tls/privkey.pem;
# --- the data-path security gate ---
ssl_verify_client on; # ON, never `optional`
ssl_client_certificate /etc/relay/device-ca/device-ca.cert.pem;
ssl_verify_depth 1;
ssl_crl /etc/relay/device-ca/crl.pem; # revocation from day one
location / {
proxy_pass http://127.0.0.1:7080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Origin https://$host; # needed for Android/curl; redundant-harmless for iOS
proxy_set_header X-Client-Cert-CN $ssl_client_s_dn;
proxy_buffering off;
proxy_read_timeout 3600s; # prevents idle-WS proxy kill (default 60s)
proxy_send_timeout 3600s;
}
}
```
- **Verify (loopback, before touching the stream), with `test-device`:** no-cert → TLS handshake failure (must fail); `--cert/--key` → frp 404 (reaches frps); revoked cert (added to CRL) → handshake failure.
**V5 — stream SNI additions (~30 min)**
- [ ] Merge into the **captured** `map` body (do not retype the existing two lines):
```nginx
frp.terminal.yaojia.wang 127.0.0.1:7000; # exact wins over wildcard
*.terminal.yaojia.wang 127.0.0.1:8470;
# existing *.term.yaojia.wang → 8443 and default → 10443 preserved verbatim
```
- [ ] `nginx -t && systemctl reload nginx`.
- **Verify:** 4-way `s_client -servername` matrix (frp control bytes / `*.terminal` LE cert / relay cert / xray real-MS cert). **Risk:** map typo → matrix + `.bak` + reload gate.
**V6 — End-to-end + acceptance (~1.5h)** — test `frpc` → local base app; full acceptance matrix (see M1). **Verify:** items 17 in Milestone M1.
**V7 — Per-host runbook + committable template (~45 min)**
- [ ] `deploy/frp/frpc.example.toml` (no secrets) + short runbook: `BIND_HOST=127.0.0.1`, exact `ALLOWED_ORIGINS`, secure P12/cert delivery (scp not email), `revoke-device.sh` procedure, the frp-client trust-boundary note.
**Track-V effort: ~810h.** New files touch nothing existing (all loopback listeners + two additive `map` lines).
---
### Track S — Server / host packaging (base app — **zero `src/` changes**)
> Headline (verified): a working tunnel needs **zero base-app code changes** — env-extensible `ALLOWED_ORIGINS`, no `Host` reliance, PTY⊥WS decoupling. But "working" ≠ "safe": the tunnel MUST NOT be switched on until **S-GATE** is green.
**S-GATE — Preconditions (BLOCKING; before `frpc` starts on ANY host)**
- [ ] **Declare the trust model** (Model A / Model B) in the deploy doc — one line. v1 = Model A.
- [ ] **Prove mTLS is enforced / cert-free is rejected** (owned by Track V): `curl -sI https://<name>.terminal.yaojia.wang/live-sessions` **with no cert** → TLS failure. If it returns `200`, **STOP — the shell is world-open.**
- [ ] **Prove a client can present a cert** (owned by Track C): `curl --cert dev.crt --key dev.key -sI …/live-sessions` → `200`.
- **Effort:** 0.5h + cross-track wait. **Risk if skipped:** unauthenticated public root shell.
**S0 — Base-app config per host (no code)**
- [ ] Run the **compiled** app: `npm ci && npm run build` → `node dist/server.js` (`build:web` only if this host also serves the browser cockpit).
| Var | Value | Note |
|---|---|---|
| `PORT` | `3000` | frpc dials `127.0.0.1:3000`; desktop-embedded may drift off 3000 — pin it (S2). |
| `BIND_HOST` | `127.0.0.1` | **Mandatory.** Default is `0.0.0.0` (`src/config.ts:37`) → leaving it default exposes an **unauth'd shell on the LAN**, bypassing mTLS entirely. |
| `ALLOWED_ORIGINS` | `https://<name>.terminal.yaojia.wang` | Bare host. `:443` also matches (URL normalizes it away on both sides; iOS omits it) — **not** a failure mode. A *non-default* port would mismatch. |
| `SHELL_PATH` | `/bin/zsh` · `/bin/bash` · `powershell.exe` | per-OS |
| `IDLE_TTL` | ≥ `86400` (raise for multi-day walk-away) | reaps only after detached **and** silent past TTL |
| `USE_TMUX` | `1` on *nix, `0`/unset on Windows | cross-restart PTY survival |
- **Origin note:** the client must send *some* `Origin` matching `ALLOWED_ORIGINS` or it 401s — a **functional** contract, not an authz boundary (a native client can send any Origin). Already satisfied: `HostEndpoint.originHeader` and the server's `isOriginAllowed` both normalize via WHATWG URL.
- **Verify:** `lsof -iTCP:3000 -sTCP:LISTEN` shows `127.0.0.1:3000` (not `*`); Origin-gate curl pair (101 good / 401 bogus). **Effort:** 0.5h/host.
**S1 — frpc on the host**
- [ ] `frpc.toml` (see M1 for the full block): `type=http`, unique `subdomain=<name>`, `transport.tls.serverName=frp.terminal.yaojia.wang`, `disableCustomTLSFirstByte=true`, frp-client cert/key (V1 control mTLS), `loginFailExit=false`, token `chmod 600`.
- [ ] Keep a **name registry** (frps rejects dup subdomains; while a host's frpc is offline anyone with the token could claim its subdomain — closed in Model A by the frp-client cert).
- **Verify:** frpc log `start proxy success`; S-GATE curl-with-cert → 200; kill frpc → app + live PTY survive → restart → client reattaches with replay. **Effort:** 12h first host, ~15 min after.
**S2 — Durable service packaging (the real work; ~1.52.5 days)**
- [ ] Port the *shape* of `agent/src/service/{launchd,systemd,install}.ts`, but note the gaps: the **launchd writer has no `<EnvironmentVariables>`** and the **systemd writer has no `EnvironmentFile`** — extend both to inject S0 env. `agent/src/service/originConfig.ts` writes the **wrong zone** (`.term.` not `.terminal.`) — parameterize the zone or write fresh.
- [ ] Two supervised user-scoped units per host (app + frpc), never root (keep the `RootRefusedError` pattern). Linux: `loginctl enable-linger`. Windows: WinSW/nssm, `USE_TMUX=0`.
- [ ] **Desktop-embedded hosts:** `pickFreePort` can drift off 3000 → **pin the port**; inject `ALLOWED_ORIGINS` (today `buildServerEnv` only relays ambient env). The Electron app must be **always-running** for the embedded server to exist → for walk-away hosts, prefer the standalone `node dist/server.js` service over the GUI.
- **Verify:** reboot → both units up, tunnel serves within ~30s; `kill -9` each → supervisor restarts. **Effort:** 1.52.5 days for env-injecting cross-OS templates + install script + 80%-coverage tests; ~15 min/host after.
**S3 — Survival & health validation (~12h)**
- Verified guarantees hold: PTY⊥WS (`src/server.ts:841` close→`detachWs`, never kill), idle reaper reaps only detached+silent (`:686`), client backoff-reconnect with `sessionId` replay.
- [ ] **Idle-WS timeout (was missing):** the server sends **no** WS pings. Fixed cross-track by V4's `proxy_read_timeout 3600s` **and** client periodic pings (iOS `URLSessionWebSocketTask.sendPing`).
- **Verify matrix:** (a) kill frpc mid-session → PTY alive, reattach on restart; (b) `IDLE_TTL=60` → detached+silent reaped, active not; (c) **idle 5 min, tunnel up → WS not dropped**; (d) reboot VPS → frpc re-establishes, client resumes.
**S4 — mTLS scoping (Model A: accept; Model B: conditional-CRITICAL)**
- [ ] Multi-tenant cert→host authorization (CRITICAL in B, N/A in A): per-tenant client-CA selected by `$host`/SNI, or `map $host → $expected_ou` reject on mismatch.
- Notes for the record: `/hook*` is tunnel-reachable (`isLoopback()` sees frpc's `127.0.0.1`) and `/live-sessions/:id/preview` (`:323`) **leaks scrollback** to any cert-holder with no Origin guard — sets Model B's blast radius (a foreign valid cert reads your terminals, not just opens new ones). Accept in Model A.
**Track-S effort: ~2.53.5 days** (S2 tooling + S3 validation) + a blocking wait on V and C tracks for the S-GATE proof. Zero `src/` changes.
---
### Track C — Native clients (present the device cert)
**Client preconditions (gate on these; mostly infra):** P-A `BIND_HOST=127.0.0.1` on every host; P-B frps `:7080`/`:7000` loopback-only & absent from the Aliyun SG (`nmap … -p 7000,7080` closed); P-C LE wildcard live (self-signed fails iOS ATS + Chromium + Android default TLS); P-D mTLS `on` not `optional`, trust anchor = device-CA only; P-E scope decision (A/B).
#### C-iOS — do first (client exists; ~57 dev-days)
**iOS-1 · `ClientTLS` leaf SPM package** (`ios/Packages/ClientTLS/`, imports `Security`/`Foundation`)
- [ ] `ClientIdentity.swift` — `@unchecked Sendable` wrapper over `SecIdentity` + issuer chain.
- [ ] `PKCS12Importer.swift` — `SecPKCS12Import`; map `errSecAuthFailed`→`.wrongPassphrase`, `errSecDecode`→`.corruptFile`, `errSecPkg`→`.unsupported`.
- [ ] `KeychainClientIdentityStore.swift` — persist raw `.p12` + passphrase under `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, re-import at launch.
- [ ] `MutualTLSChallengeResponder.swift` — pure `resolve(challenge, identity:)`: `ClientCertificate`+identity → `.useCredential(URLCredential(identity:…, persistence:.forSession))`; `ClientCertificate`+no identity → `.cancelAuthenticationChallenge` (clean, classifiable error); `ServerTrust` → `.performDefaultHandling` (LE validated by system).
- **Verify:** `swift test --package-path Packages/ClientTLS` — responder truth table (3 challenge types × identity present/absent) + import of a CI-generated fixture `.p12` incl. wrong-passphrase.
**iOS-2 · Wire responder into both transports**
- [ ] `URLSessionTermTransport`/`WSConnection`: add `init(identity:)`; implement **`urlSession(_:task:didReceive:completionHandler:)`** (task-level receives connection-level challenges) delegating to the responder; keep `didCompleteWithError` intact. *(Note: the current transport delegate has no challenge handler at all — this is net-new.)*
- [ ] `URLSessionHTTPTransport`: build the session with an `NSObject` `ClientTLSSessionDelegate` implementing the session-level `urlSession(_:didReceive:completionHandler:)`. Apply to both `production()` and `SessionThumbnail.liveTransport` (`SessionThumbnail.swift:242`).
- [ ] `AppEnvironment.production()`: load identity from the store, inject into both transports + probe closure.
- **Verify:** `xcodebuild test`; `FakeChallenge` unit test asserts each transport returns the credential iff an identity is set.
**iOS-3 · Install UX + pairing guard + reworded warning**
- [ ] `ClientCertScreen.swift` — `.fileImporter([.pkcs12])` + secure passphrase field → import → store → show issuer CN + **expiry** + "replace certificate" (rotation). Settings entry "Device certificate."
- [ ] `PairingViewModel`: for a `.terminal.yaojia.wang` host, (a) block probe if no identity installed ("先安装本设备证书"); (b) **replace the `publicHostBlocking` copy** for tunnel hosts (the current "anyone who can reach the port gets a shell" is false under mTLS and deters the intended flow). Keep the blocking tier for genuinely public non-tunnel hosts.
- [ ] Add a `.clientCertRejected` classification (nginx-rejected mTLS surfaces as `NSURLErrorSecureConnectionFailed`/reset — currently mis-classified `.tlsFailure` "server cert invalid"). Copy: "本设备证书无效或已吊销,请重新导入。" *(`PairingError.swift` already maps `NSURLErrorClientCertificateRequired/Rejected` — primed.)*
- **Install UX:** deliver `device.p12` (AirDrop/Files) → WebTerm → Settings → Device certificate → Import + passphrase (in-app, app-sandboxed; not a config profile).
- **Verify:** XCUITest import fixture → pair `https://<name>.terminal.yaojia.wang` → probe → attach. Manual on-device against the real VPS; confirm nginx logs `$ssl_client_verify=SUCCESS`.
#### C-Desktop — do second (Electron; ~69 dev-days) — reverses `DESKTOP_PLAN.md §0` (record the deviation)
**D-1 · Remote-host mode**
- [ ] `desktop/src/remote-hosts.ts` — `RemoteHost{id,name,url}`, persisted via `settings-store.ts` (extend `DesktopPrefs`).
- [ ] `window.ts`/`main.ts`: allow a `BrowserWindow` to load `https://<name>.terminal.yaojia.wang/`; **relax the `will-navigate` block to exactly the selected remote origin** (keep it locked to that one origin; keep the embedded local server running — this box is also its own tunnel source). Tray host-picker: "This machine (local)" ↔ each remote host.
- **Verify:** `npm run typecheck`; switch to a remote host, confirm page + `wss://` load; confirm the origin lock still blocks elsewhere.
**D-2 · mTLS via OS store (the only viable path)**
- [ ] `main.ts`: `app.on('select-client-certificate', (event, wc, url, list, cb) => { event.preventDefault(); const cert = pickByIssuer(list, DEVICE_CA_CN); cb(cert) })`. **`event.preventDefault()` is mandatory** (else Electron auto-picks `list[0]`). Empty/no-match → clear "device certificate not installed" dialog, never silent `cb()`.
- **Drop** the "app reads `.p12`" idea — infeasible: Chromium sources client certs **only** from the OS store; `setCertificateVerifyProc` is server-side only. The `.p12` must live in the login keychain / Personal store. Document the macOS private-key ACL "Always Allow" one-time prompt.
- **Install UX:** macOS double-click `.p12` → login keychain; Windows → Personal store; app auto-selects by issuer.
- **Verify:** end-to-end connect; nginx logs a verified cert; empty keychain → dialog, not a hang.
**D-3 · Local `frpc` supervision (coordinate with Track V/S)**
- [ ] Optionally spawn/monitor `frpc` from `main.ts` and set the embedded server's `ALLOWED_ORIGINS=https://<name>.terminal.yaojia.wang` (additive via `config.ts:214`). Embedded server already defaults `BIND_HOST=127.0.0.1` (`server-config.ts:17`) — satisfies P-A **unless `lanSharing` is enabled** (document: re-opens the unauth'd LAN path).
- **Verify:** desktop-launched frpc registers; host reachable from an off-LAN client.
#### C-Android — do last (no `android/` exists; WebView MVP ~2.53.5 wk)
Pick **WebView shell** (mirrors the desktop pattern; xterm.js in `public/` is the terminal; avoids the Termux GPLv3 entanglement of full-native).
- [ ] **C-1 Shell:** Kotlin, single Activity, hardened `WebView` (JS on, file access off, one allowed origin). Verify: loads a base app.
- [ ] **C-2 mTLS:** `WebViewClient.onReceivedClientCertRequest` → `req.proceed(privateKey, chain)` from `KeyStore.getInstance("PKCS12")`; import via SAF (`ACTION_OPEN_DOCUMENT`) + passphrase; `.p12` app-private, key wrapped by Android Keystore. Verify: connect to `<name>.terminal.yaojia.wang`; nginx logs verified cert; wrong/absent → clean error UI.
- [ ] **C-3 Host mgmt:** multi-host list (DataStore/Room), add/switch/remove.
- [ ] **C-4 Notifications + deep links:** reuse the ntfy bridge (matches existing infra) rather than FCM; `webterminal://` intent filter.
- Defer full-native (Compose + terminal-view + OkHttp `X509KeyManager`) — 48 wk, pulls in GPLv3.
**Track-C effort: iOS 57 d · Desktop 69 d · Android 2.53.5 wk**, gated on preconditions.
---
## 3. Cross-track dependencies & build order
**Dependency graph**
- `Track V (V0→V6)` proves the server-side gate → **unblocks S-GATE #2** and the entire tunnel-enable step.
- `Track C iOS-1/2` (first client cert support) → **unblocks S-GATE #3**.
- `Track S S-GATE` gates `S1` (frpc on any host) on **both** V's cert-rejection proof and C's cert-presentation proof.
- `V3 (LE wildcard)` is a **hard prerequisite** for all three clients (P-C) — do it early.
- `V4 proxy_read_timeout` + client pings jointly fix idle-WS kill (S3).
- Desktop `D-3` and Android depend on the per-host runbook `V7` + service tooling `S2`.
**Concrete build ORDER**
1. **V0 → V5** (VPS: frps + device-CA + LE + nginx mTLS + SNI routes). Fully self-verifiable with `curl --cert` / `websocat`.
2. **V6** end-to-end with a throwaway `frpc` + a spare base app on the VPS/laptop → **Milestone M1** (mTLS proven with `curl`, no client yet).
3. In parallel once M1 is green: **C-iOS (iOS-1 → iOS-3)** and **S0/S1** on the first real local host.
4. **First real end-to-end from iOS** → **Milestone M2**.
5. **S2** durable service tooling → **S-GATE** repeatable per host → onboard a 2nd/3rd host → **Milestone M3**.
6. **C-Android** WebView MVP → **Milestone M4**.
7. **C-Desktop** remote mode + OS-store mTLS → **Milestone M5**.
---
## 4. Milestones
- **M1 — VPS tunnel up + one local host + mTLS proven with ONE device (curl) end-to-end.** Acceptance matrix on `t1.terminal.yaojia.wang` (base app `BIND_HOST=127.0.0.1 ALLOWED_ORIGINS=https://t1.terminal.yaojia.wang PORT=3000`; `frpc.toml`):
```toml
serverAddr="8.138.1.192"; serverPort=443; auth.token="<FRP_TOKEN>"
transport.tls.enable=true
transport.tls.serverName="frp.terminal.yaojia.wang"
transport.tls.disableCustomTLSFirstByte=true # CRITICAL for ssl_preread
transport.tls.certFile="<host-frp-client.pem>" # frps control mTLS
transport.tls.keyFile="<host-frp-client.key.pem>"
transport.tls.trustedCaFile="/etc/.../frps-ctrl-ca.pem"
[[proxies]]
name="t1"; type="http"; localIP="127.0.0.1"; localPort=3000; subdomain="t1"
```
1. no device cert → TLS handshake failure. 2. `--cert test-device.pem --key …` → `200` JSON from `/live-sessions`. 3. WS `attach`→`attached` on `wss://t1.terminal.yaojia.wang/term`. 4. **H1 negative:** a 2nd frpc with the token but no/other frp-client cert claiming `t1` → refused at control. 5. revoked device cert → rejected (CRL). 6. first byte to `:443` is `0x16` (`tcpdump`, proves `disableCustomTLSFirstByte`). 7. 4-zone SNI + V0 coexistence oracle green; `journalctl -u xray -u nginx` clean.
- **M2 — iOS mTLS end-to-end.** Real iPhone with an imported `device.p12` pairs `https://t1.terminal.yaojia.wang`, probes `/live-sessions`, attaches `/term`, and drives a live shell; nginx logs `$ssl_client_verify=SUCCESS`; idle-5-min WS stays up.
- **M3 — Multi-host.** Two+ real local hosts (`h1`, `h2`) each running the durable service (S2) + frpc; each reachable at its own subdomain; onboarding a new host = runbook only (no VPS change); revoke one device removes its access fleet-wide.
- **M4 — Android WebView MVP.** Android device imports `.p12`, connects to a host over mTLS, manages ≥2 hosts, receives an ntfy push → deep-links into a session.
- **M5 — Desktop remote mode.** Electron app switches to a remote host, selects the device cert from the OS keychain, connects over `wss://` + mTLS; origin lock still blocks foreign navigation.
---
## 5. Risk register
| # | Risk | Sev | Mitigation |
|---|---|---|---|
| **R1** | **mTLS client-cert support is net-new on all three clients** (iOS transport has no `didReceive challenge`; Android/desktop from-scratch). Until it ships, no client can reach a tunneled host. **Critical-path blocker.** | **CRITICAL** | Do iOS first (smallest, exists) to prove the whole path; VPS side is fully verifiable now with `curl --cert`/`websocat` independent of clients. LE wildcard (V3) de-scopes clients to only the client-cert branch. |
| **R2** | **Base-app public exposure — mTLS is the ONLY gate and is bypassable.** `BIND_HOST` default `0.0.0.0` serves an **unauth'd shell on the LAN**; frps `:7080`/`:7000` off-loopback or a single `ssl_verify_client optional`/misconfig = every session world-open; `/live-sessions/:id/preview` leaks scrollback to any cert-holder. | **CRITICAL** | Mandatory `BIND_HOST=127.0.0.1` (S0, P-A); frps loopback + absent from Aliyun SG (P-B, verify `nmap`); `ssl_verify_client on` never `optional` (P-D); **S-GATE blocks all tunnel-enable** until cert-free rejection is proven; CRL from day one (V2/V4). |
| **R3** | **frps registration = subdomain takeover / active MITM.** Shared token isn't bound to a subdomain → any token-holder registers any subdomain and MITMs a valid-cert client. | HIGH | Control-channel mTLS (V1, per-host frp-client CA) so a leaked token alone is insufficient; NewProxy authz plugin pins cert→subdomain for Model B (V1b). |
| **R4** | **No cross-tenant isolation under one device-CA** (Model B). | HIGH (B only) | Declare **Model A** for v1; Model B needs per-tenant CA / `$host`→OU map + per-host frp token. Do not ship B on shared-CA. |
| **R5** | **Self-signed server cert is a hard iOS blocker** (URLSession has no tap-to-accept). | HIGH | LE DNS-01 wildcard is mainline (V3) — system-trusted on all three platforms. |
| **R6** | **Idle-WS proxy kill** (~60s nginx default, no app-level pings) → reconnect churn. | HIGH | V4 `proxy_read_timeout/proxy_send_timeout 3600s` + client periodic WS pings; explicit idle-5-min test (S3, M2). |
| **R7** | **`http2 off;` invalid on nginx 1.24** → `nginx -t` fails, reload aborted. | HIGH (deploy) | Omit the directive (HTTP/2 off by default); pin at V0 via `nginx -v`. |
| **R8** | **No revocation** → one lost device = fleet access until full CA rotation. | HIGH | `ssl_crl` + `revoke-device.sh` from day one (V2); client re-import/rotate path (iOS-3, D-2, C-2). |
| **R9** | **Desktop-embedded weak substrate** — dynamic port drift off 3000; GUI must stay running. | MED | Pin the port + inject `ALLOWED_ORIGINS`; prefer standalone `node dist/server.js` service for walk-away hosts. |
| **R10** | **Env-injection missing** in `launchd`/`systemd` writers; `originConfig.ts` uses wrong zone. | MED | Treat S2 as real work (~1.52.5 d), not copy-paste; parameterize the zone. |
| **R11** | **Cert-provisioning ops burden** grows with device count. | MED | Small enrollment helper wrapping `issue-device-cert.sh`; secure `.p12` delivery (scp/AirDrop, not email). |
| **R12** | **LE DNS-01 depends on Aliyun API creds/propagation.** | MED | Provision `Ali_Key`/`Ali_Secret` early; if unavailable, iOS bring-up stalls — sequence V3 before M2. |
---
## 6. MVP fast-path — shortest route to ONE device securely reaching ONE host
Goal: an iPhone opens a shell on your laptop through the VPS, with mTLS as the only gate, in the fewest steps. **Model A. Defer** the NewProxy authz plugin, multi-host tooling, Android, desktop, and durable service packaging.
1. **VPS `V0`→`V5`** but minimal: install frps with **control mTLS + token** (V1), `gen-device-ca.sh` + `issue-device-cert.sh` + empty CRL (V2), **LE wildcard** (V3, non-negotiable for iOS), nginx `:8470` mTLS (V4), two SNI `map` lines (V5). *(~56h.)*
2. **`V6`/M1 with curl:** run one throwaway base app + `frpc name=t1` on the laptop (`BIND_HOST=127.0.0.1`, `ALLOWED_ORIGINS=https://t1.terminal.yaojia.wang`). Prove: no-cert → reject; `curl --cert` → `200`; `websocat` `attach`→`attached`. **→ M1.**
3. **iOS client cert only:** build `ClientTLS` (iOS-1) + wire the two transports (iOS-2) + a minimal import screen (iOS-3, skip the reworded-warning polish for now). Import `device.p12`, pair `https://t1.terminal.yaojia.wang`, attach. **→ M2.**
**Fast-path total: ~1.52 days VPS + ~45 dev-days iOS.** Everything else (authz plugin, S2 durable services, multi-host, Android, desktop remote mode) layers on afterward without reworking this core.

172
docs/PLAN_RELAY_PHASE1.md Normal file
View File

@@ -0,0 +1,172 @@
# PLAN_RELAY_PHASE1 — deploy the native rendezvous-relay to a single VPS (staging, durable)
> **Decision (2026-07-06):** Build **Phase 1 "1b — full durable staging"** on cloud VPS `8.138.1.192`
> (Alibaba Cloud, mainland). Real **Postgres + Redis via Docker Compose on the VPS**; real **TLS on
> `:443` via Let's Encrypt** against an **already ICP-filed domain**; agent runs on the operator's own
> machine and dials OUT. This plan is the file-level execution spec derived from a full code audit of
> the 7 relay packages. It supersedes the high-level phasing in [DEPLOY_RELAY.md](./DEPLOY_RELAY.md) §4
> for the concrete build; that doc still holds for the *why* and the security runbook (§7).
>
> **Scope OUT (→ Phase 2):** real KMS custody of the CA (dev in-process signer is accepted for
> staging), F6 recoverable-replay transport (`loadReplay` stays fail-closed — not on the terminal path),
> WebAuthn/passkey step-up (staging uses `NO_STEPUP_POLICY` — single operator), wildcard multi-tenant
> subdomains, metering/alerting. Single tenant, single host, single relay node.
---
## 0. Target (what "done" means)
From the operator's laptop (agent dialing out) and any browser:
1. `https://<sub>.<BASE_DOMAIN>` serves the relay-web bundle (same origin as the WSS endpoint).
2. Operator logs in, picks the host, and gets a live shell **spliced by the real relay-node** — the
relay only sees ciphertext (INV2); E2E is browser↔agent.
3. Agent enrolled once via a pairing code → holds a SPIFFE mTLS cert → dials `wss://…:AGENT_PORT`.
4. **Restart-safe:** bouncing the relay/control-plane process does NOT lose the host registration or
kill the operator's PTY (state is in Postgres/Redis; INV7).
5. Revoking the host tears the tunnel down within the INV12 budget (Redis `relay:revocations`).
---
## 1. Code audit summary — what exists vs. what must be built
Security-critical *logic* is written and injectable across all 7 packages. Phase 1 = build the
integration + infra layer they were designed to receive. Grounded gaps:
| Area | State (file evidence) | Task |
|---|---|---|
| P3 Postgres adapter | **absent**`store/pg.ts` does not exist; only `db/pool.ts` (`createPgPool`/`createQuery`) + full `db/migrations/0001_init.sql`. `memory.ts` is the semantics reference (INV8 versioning, CAS). | **A1** |
| P3 migration runner | none | **A1** |
| P3 server entrypoint | **absent** — no `.listen()`, no `start`; only `buildControlPlane(env, overrides)` factory (`main.ts:60`). | **A2** |
| P3 Redis revocation bus | `createRedisRevocationBus(RedisPublisher)` exists (`routing/bus.ts:42`) but no `ioredis` client instantiated; `main.ts:93` leaves `bus` inert. | **A2** |
| P3 F2 capability verifier | throwing stub `refuseAllVerifier` (`main.ts:44`). Must inject relay-auth `verifyCapabilityToken`. **Impedance:** CP's `CapabilityVerifier.verify` is **sync** (`api/authz.ts:24`), relay-auth's is **async** → seam must go async. | **A3** |
| relay EnforceDeps over shared store | relay-run uses its own in-RAM fakes (`relay-run/src/wiring/memory-stores.ts`), a **separate world** from the CP. Must implement `HostRegistryPort`/`SessionRegistryPort`/`RevocationStore`/`TokenBucketStore`/`AuditSink` over the **same** Postgres/Redis. | **B1** |
| relay `MtlsVerifier` | stub returns seeded host for any cert (`relay-world.ts:149`). Must use relay-auth `verifyAgentCert(leaf, caChain, now, hosts)` against the shared host registry + pinned agent CA. | **B2** |
| relay `RouteResolver` | one-entry in-RAM map (`data-plane.ts:110`). Must resolve subdomain→hostId from `hosts.getBySubdomain`. | **B3** |
| relay revocation subscriber | not wired. Subscribe `relay:revocations``killsScope``closeStream`. | **B4** |
| relay-run Phase-1 entry | `main.ts` hardcodes `127.0.0.1:8443`, `baseDomain='term.localhost'`, self-signed certs, dials no agent, `NO_STEP_UP`. | **B5** |
| agent build | `noEmit:true`, no `build` script, `dist/cli.js` never produced. | **C1** |
| agent runnable entry | `cli.ts` exports `parseArgs`/`runCli` but has **no `main()`/argv bootstrap, no `CliDeps` factory, no concrete `runTunnel`** (only assembled in `test/acceptance/cafeDemo.test.ts`). | **C2** |
| relay-web static serve | `build.mjs` emits `public/build/`; **no HTTP server** ships. Must serve `public/` same-origin as WSS. | **D1** |
| Infra | no Dockerfiles/compose/systemd anywhere. | **E** |
---
## 2. Build order (waves by dependency)
```
A (P3 durable + serving) ──▶ B (relay data-plane on shared store) ──▶ E (infra + wire-up)
└──▶ C (agent buildable + runnable) ──────────▶
└──▶ D (relay-web served) ────────────────────▶
```
A is the foundation (both planes read the same store). B/C/D can proceed once A's store contract is
green. E stands up infra and does the end-to-end enroll→dial→click-through.
### Wave A — control-plane durable + serving
- **A1 · PG store adapter + migration runner.** `Owns: control-plane/src/store/pg.ts`,
`control-plane/src/db/migrate.ts`, `control-plane/test/store/pg.test.ts`.
`createPgStores(query: QueryFn): Stores` implementing all 9 ports (`store/ports.ts`) with **byte-for-byte
semantics parity with `memory.ts`**: INV8 status-version+pointer swaps in one transaction, `casRedeem`
single-winner, `registerFailure` increment, `reserve` single-winner on the `subdomain UNIQUE`
constraint, TTL on routes (store `expires_at`, filter on read — Postgres has no key TTL). Map
snake_case columns ↔ camelCase records. Migration runner applies `db/migrations/*.sql` idempotently.
**Verify:** TDD against a real Postgres (Testcontainers or a Docker `postgres:16` on
`127.0.0.1:5432`); run the SAME behavioral suite the memory store passes.
- **A2 · P3 server entrypoint + Redis wiring.** `Owns: control-plane/src/server.ts`,
`control-plane/src/boot/redis.ts`, `package.json` (`start` script).
`loadEnv(process.env)``createPgPool(PG_URL)`+`createQuery``createPgStores` → migrate →
`ioredis` client → `createRedisRevocationBus(redis)``buildControlPlane(env, {stores, bus,
verifier, caChainDer})``app.listen({host:'0.0.0.0', port})`. Graceful SIGTERM. **Verify:** boots
against Docker PG+Redis; `POST /accounts` then `POST /accounts/:id/pairing-codes` round-trips.
- **A3 · F2 capability verifier (async).** `Owns: control-plane/src/api/authz.ts` (make
`CapabilityVerifier.verify` return `Promise`, await at call sites), `control-plane/src/boot/verifier.ts`,
`control-plane/src/main.ts` (default + `overrides.verifier` plumb + call `loadVerifyKeyFromEnv`).
Real verifier delegates to relay-auth `verifyCapabilityToken(raw, expectedAud, now)`; configure the
verify key from `CAPABILITY_SIGN_PUBKEY_B64` at boot. **Verify:** a token signed by the matching key
verifies; a foreign/expired token 401s; unblocks programmatic account/pairing seeding.
### Wave B — relay data-plane on the shared store (P1 via relay-run)
- **B1 · shared-store EnforceDeps.** `Owns: relay-run/src/wiring/stores-pg.ts`,
`relay-run/test/stores-pg.test.ts`. Implement relay-auth's `HostRegistryPort`, `SessionRegistryPort`,
`RevocationStore`, `TokenBucketStore` (Redis token bucket), `AuditSink` (append to `audit_log`) over
the SAME PG/Redis as P3. Replace `memory-stores.ts` in the Phase-1 path.
- **B2 · registry-backed `MtlsVerifier`.** `Owns: relay-run/src/wiring/mtls-verifier.ts`. Wrap
relay-auth `verifyAgentCert(leafPem, caChainPem, now, hosts)`; pin the agent CA bundle; return
`{hostId, accountId}` only for enrolled+unrevoked hosts (INV4/INV14).
- **B3 · store-backed `RouteResolver`.** `Owns: relay-run/src/wiring/route-resolver.ts`.
`resolve(subdomain)``hosts.getBySubdomain` → hostId (or null).
- **B4 · revocation subscriber.** `Owns: relay-run/src/wiring/revocation-subscriber.ts` (or reuse
`term-relay/data-plane/revocation-subscriber.ts`). ioredis SUBSCRIBE `relay:revocations` → parse
`KillSignal``killsScope(signal, hostAccountId, hostId)``node.closeStream(...)`.
- **B5 · relay-run Phase-1 entry.** `Owns: relay-run/src/main-phase1.ts`, `relay-run/package.json`
(`start:phase1`). Env-driven (no hardcoding, CLAUDE §Config): bind `0.0.0.0`; `BASE_DOMAIN` +
computed `allowedOrigins` (port-less on :443, exact-match at `onUpgrade.ts:102`); real LE cert/key
paths for browser WSS; **separate** private agent-CA bundle for mTLS; `loadVerifyKeyFromEnv` at
startup; compose B1B4; keep `NO_STEPUP_POLICY` (staging). Leave the Phase-0 `main.ts` untouched for dev.
### Wave C — agent buildable + runnable (P2)
- **C1 · agent build.** `Owns: agent/tsconfig.build.json`, `agent/package.json` (`build`). Emit
`dist/cli.js` (esbuild bundle or `tsc` emit; keep `src` tsconfig `noEmit` for typecheck).
- **C2 · CLI bootstrap + `runTunnel`.** `Owns: agent/src/main.ts` (shebang + argv → `runCli`),
`agent/src/transport/runTunnel.ts`, `agent/src/cli/deps.ts` (`CliDeps` factory). Assemble
`dialRelay``holdTunnel``createStreamRouter``dialLoopback` + heartbeat/backoff, porting the proven
wiring from `test/acceptance/cafeDemo.test.ts`. **Verify:** `web-terminal-agent pair <CODE>` then
`web-terminal-agent run` enrolls + dials against a local Phase-1 relay.
### Wave D — serve relay-web (P6)
- **D1 · same-origin static server.** `Owns: relay-run/src/servers/static-web.ts` (fold into the
browser HTTPS server so the bundle is served from the SAME origin/cert as the WSS, keeping
Origin/CSP aligned). Serve `relay-web/public/` (built). Add `npm --prefix relay-web run build` to the
deploy step. `loadReplay` stays fail-closed (Phase 2).
### Wave E — infra + integration on 8.138.1.192
- **E1 · Docker Compose** `Owns: deploy/docker-compose.yml`, `deploy/.env.example`. `postgres:16` +
`redis:7`, volumes, bound to `127.0.0.1` (not public). Health checks.
- **E2 · DNS + TLS.** A-record `<sub>.<BASE_DOMAIN>``8.138.1.192`; Let's Encrypt cert
(HTTP-01 on :80 or DNS-01) for that subdomain → `TLS_CERT_PATH`/`TLS_KEY_PATH`.
- **E3 · Enrollment CA.** Generate the private agent CA (staging may reuse the CP dev signer's CA) →
`CA_INTERMEDIATE_CERT_PATH`, `NODE_MTLS_TRUST_BUNDLE_PATH`, `AGENT_CA_CERT_PATH`. **Never** LE for mTLS.
- **E4 · Env + systemd + security group.** `deploy/control-plane.env`, `deploy/relay.env`,
`deploy/*.service`; open inbound `:443` (browser) + `AGENT_PORT` (agent mTLS) in the Aliyun security
group; keep CP admin + PG + Redis loopback-only.
- **E5 · End-to-end.** `POST /accounts``POST /accounts/:id/pairing-codes` → agent `pair`+`run` on
the laptop → browser opens `https://<sub>.<BASE_DOMAIN>` → click through to the shell. Confirm
restart-safety + revocation teardown.
---
## 3. Environment / config reference (Phase 1, this VPS)
**control-plane** (`control-plane/src/env.ts`, all required unless defaulted):
`PG_URL`, `REDIS_URL`, `CAPABILITY_SIGN_PUBKEY_B64` (32-byte Ed25519, base64), `CA_INTERMEDIATE_KMS_KEY_REF`
(dev signer accepts any ref), `CA_INTERMEDIATE_CERT_PATH`, `NODE_MTLS_TRUST_BUNDLE_PATH`, `BASE_DOMAIN`;
defaulted `HEARTBEAT_TTL_SEC`=15, `PAIRING_TTL_SEC`=600, `PAIRING_MAX_REDEEM_ATTEMPTS`=5.
**relay-run Phase 1** (`term-relay/data-plane/config.ts` + new): `BASE_DOMAIN`, `BIND_HOST`=0.0.0.0,
`BIND_PORT`=443, `TLS_CERT_PATH`, `TLS_KEY_PATH`, `AGENT_BIND_PORT`, `AGENT_CA_CERT_PATH`,
`AGENT_CA_CHAIN_PATH`, `RELAY_NODE_ID`, `RELAY_AUTH_VERIFY_PUBKEY` (= `CAPABILITY_SIGN_PUBKEY_B64`,
base64url), `RELAY_TRUST_DOMAIN`, `PG_URL`, `REDIS_URL`.
**agent** (`agent/src/config/agentConfig.ts`): `RELAY_URL` (`wss://…:AGENT_PORT`), `ENROLL_URL`
(`https://<cp-host>/enroll`), `HOST_ID`, `SUBDOMAIN`, `LOCAL_TARGET_URL` (`ws://127.0.0.1:3000` — the
base app), `STATE_DIR` (`~/.web-terminal-agent`).
> **Key agreement (linchpin):** the P5 capability-signing keypair — CP signs, relay verifies. CP env
> `CAPABILITY_SIGN_PUBKEY_B64` and relay env `RELAY_AUTH_VERIFY_PUBKEY` MUST be the SAME public key.
> The **agent enrollment CA** (mTLS) and the **browser LE cert** are two independent trust chains.
---
## 4. Invariants to preserve (do not regress)
INV2 opaque splice (relay sees only ciphertext) · INV3 accountId only from authenticated material ·
INV7 PTY≠WS, relay nodes stateless/restart-safe · INV8 immutable versioned status · INV10 zero-payload
audit · INV12 revocation teardown budget · INV14 registry-gated mTLS. Origin/CSWSH exact-match on every
browser upgrade. Config via env only — no hardcoded hosts/ports/secrets.
---
## 5. Progress
Tracked in [PROGRESS_LOG.md](./PROGRESS_LOG.md) under a `RELAY-PHASE1` heading. Task IDs: `A1A3`,
`B1B5`, `C1C2`, `D1`, `E1E5`. Orchestrator appends one entry per task on completion (status, files,
verification command+result, deviations, next).

View File

@@ -24,6 +24,36 @@
> 新会话读到的第一块。保持准确,只描述"此刻"。
### 🚧 RELAY-PHASE1 — 把原生 rendezvous-relay 部署到单台 VPS(8.138.1.192,阿里云;2026-07-06)
- **目标**: DEPLOY_RELAY §4 的 **Phase 1「1b 全量持久化 staging」**。VPS 上 Docker 自建 Postgres+Redis;已备案域名走 443 + Let's Encrypt;agent 跑在操作者本机向外拨号。完整文件级方案见 [PLAN_RELAY_PHASE1.md](./PLAN_RELAY_PHASE1.md)。
- **进度**: **Wave A/B/C/D/E 代码全部完成并通过 Verify** —— 一个后台 Workflow(12 agents,0 error)跑完 A2、B1B5、B6、C、D1、E。Verify 门槛:**四个包 tsc 全干净、可构建包全 build 成功、314/314 测试通过**(control-plane 103 / relay-run 74 / agent 137 + relay-web 118)。对抗式安全评审:**所有硬不变量 PASS**(INV2 opaque splice、Origin/CSWSH 精确匹配、mTLS INV14 registry 门控、token-mint 密码学、撤销 INV12、jti 单次、参数化 SQL)。
- **各任务**: A2 P3 server 入口+Redis 总线 `[x]`;B1 共享 store EnforceDeps `[x]`;B2 registry-backed MtlsVerifier `[x]`(异步阻抗由 B5 的 `bridgeAsyncMtls` per-DER 预取桥无锁解决);B3 store RouteResolver `[x]`;B4 撤销订阅 `[x]`;B5 `main-phase1.ts` 组合入口 + staging `/auth/mint` 签发 `[x]`;C agent `dist/cli.js` 构建+`runTunnel` 运行入口 `[x]`;D1 同源托管 relay-web `[x]`;E systemd+脚本+RUNBOOK `[x]`;**B6 relay-web 登录/DPoP `[~] PARTIAL`**。
- **B7 收尾 `[x]` DONE**: ① 功能阻塞已闭环 — `browser-server.ts` 现从 `term.dpop.<b64u>` 子协议读 DPoP proof 喂 `UpgradeRequest.dpop`(头优先、否则回退子协议),真实浏览器可通过 DPoP 门。② F1 `/auth/mint` 加 Redis 按 IP 限流(HMAC 盐哈希 key,burst5/refill0.2,早于口令比对,耗尽 429);F2 `activeSessionCount` 接真实同租户在线 WS 计数;F5 错误日志只记 `e.message(+.code)` 防 DSN 泄漏。relay-run `npx vitest run`**10 files / 92 passed**、tsc 干净。**F3/F4(撤销后 agent 空转重连、slowloris)为 LOW → Phase 2 backlog。**
- **⚠️ 端到端仅可在 VPS 验证**:浏览器登录→token→DPoP→upgrade→agent 拼接,本地只到类型/单元层。E2E5 远端执行见 `deploy/RUNBOOK.md`(需 VPS 访问权)。
- **远端(需 VPS 执行,见 `deploy/RUNBOOK.md`)**: DNS、Let's Encrypt 签证、私有 enrollment CA + capability keypair 生成、阿里云安全组放行 443/AGENT_PORT、systemd 起两个服务、端到端 enroll→dial→浏览器点进 shell。
- **未做/推迟到 Phase 2**: 真 KMS、F6 replay、WebAuthn step-up(staging 用 `NO_STEPUP_POLICY`)、通配多租户、F3/F4。
- **未做/推迟到 Phase 2**: 真 KMS(dev signer 暂用)、F6 replay(`loadReplay` fail-closed)、WebAuthn step-up(staging 用 `NO_STEPUP_POLICY`)、通配多租户。
#### [x] A1 — Postgres store 适配器(P3)+ 迁移运行器(2026-07-06)
- **交付**: `createPgStores(query): Stores` 把 9 个 store 端口映射为参数化 SQL(零字符串拼接);`runMigrations(query)` 按文件名顺序跑 `db/migrations/*.sql`(幂等)。
- **文件(全在 Owns 内)**: `control-plane/src/store/pg.ts``src/db/migrate.ts``db/migrations/0002_routes.sql`(新增 — 0001 无 routes 表)、`test/store/pg.test.ts`
- **语义对齐 memory.ts**: INV8 accounts/hosts `swapStatus` 用单条 **writable CTE**(UPDATE 指针 + INSERT 版本行)原子完成,无显式事务;重复插入捕获 pg `23505``throw duplicate`;`casRedeem` 单赢家 `UPDATE...WHERE redeemed_at IS NULL RETURNING`;RouteStore 存 `expires_at`,`expires_at<=now()` 视为不存在(fail-closed INV7)+ 惰性 DELETE。
- **验证(实测)**: `npx vitest run test/store/pg.test.ts`**23/23 通过**(自起 `postgres:16` 一次性容器 :5433,afterAll 自动 `docker rm -f`);`pg.ts` 覆盖 **96.72% stmts / 100% funcs**
- **偏差**: ① `0002_routes.sql` 新增;② SubdomainStore 无独立预留表,按 `hosts.subdomain UNIQUE` 归属裁决;③ migrate 按 `;` 切分逐条执行(参数化路径拒绝多语句)。**阻塞**: 无。
#### [x] A3 — 真能力验证器(relay-auth verifyCapabilityToken)+ 同步→异步 seam(2026-07-06)
- **交付**: 抛异常的 `refuseAllVerifier` stub 换成委托 P5 异步 `verifyCapabilityToken` 的真验证器;`await` 贯通整个 authorizer 路径。默认仍 fail-closed(异步化后 reject→401)。
- **文件**: `src/api/authz.ts`(`CapabilityVerifier.verify``Promise`)、`src/api/provision.ts`(5 个路由 call site 改 `await principal(req)`)、`src/boot/verifier.ts`(新;`configureCapabilityVerifyKey``env.capabilitySignPubkey` 32 字节导入 WebCrypto verify key 后调 relay-auth `configureVerifyKey`,桥接 `CAPABILITY_SIGN_PUBKEY_B64` vs `RELAY_AUTH_VERIFY_PUBKEY` 命名差)、`src/main.ts``package.json`(加 `relay-auth: file:../relay-auth`)、`test/verifier.test.ts`(新)+ 更新 api 测试 stub 为异步。
- **验证(实测)**: `npx tsc --noEmit` 全项目干净;`npx vitest run` **15 files / 101 tests pass**
- **偏差**: 手建 `control-plane/node_modules/relay-auth` 符号链接以配合新 file: 依赖(`npm install` 会重建)。**阻塞**: 无。
### ✅ 修复:底部快捷键栏遮挡终端内容(iOS 安全区,2026-07-06,main)
- **现象(用户截图,iPhone)**: 底部快捷键栏(Esc/Esc²/⇧Tab…)压住终端最后一行(Claude Code "bypass permissions" 提示行只露上半)。
- **根因(`public/`)**: `index.html` 设了 `viewport-fit=cover`(布局铺满物理屏、延伸进刘海/Home 指示条),但 `style.css` **全程没有任何 `env(safe-area-inset-*)` 补偿**。于是 `#keybar`(`fixed; bottom:0; height:--keybar-h`)整条压进 Home 指示条区,而 `#term` 只预留了裸 `--keybar-h`,末行与键栏相撞。教科书级 cover-无-safe-area 缺陷。
- **修复(纯 CSS,零 JS)**: `:root` 新增 `--safe-t/--safe-b = env(safe-area-inset-top/bottom, 0px)`,穿过所有贴边固定元素:`#tabbar` 顶部让出刘海(border-box + `padding-top:--safe-t`)、`#term` inset 改 `calc(--tabbar-h+--safe-t) … calc(--keybar-h+--safe-b)``#keybar` 高度 `calc(--keybar-h+--safe-b)` + `padding-bottom:--safe-b`(芯片留在顶部 --keybar-h,Home 条上方)、`#approvalbar``body.home-open #term` 底部、`#searchbox`/`#settingspanel` 顶部同步。inset=0 时全部塌回原值 → 桌面/无安全区平台**零回归**。
- **验证(headless,390×844,注入 `--safe-t:59px --safe-b:34px` 模拟 iPhone)**: 开一个真实会话后量测 `term_bottom==keybar_top==764`(gap=0,无重叠)、`tabbar_bottom==99`(=40+59,让出刘海);截图确认芯片位于 Home 指示条上方、终端不再被遮。`npm run build:web` 通过。
- **待办**: 未提交;未在真机 iPhone 上复验(仅 headless 模拟安全区)。
### ✅ 修复:项目面板把父文件夹当项目 & 会话点亮所有祖先项目(2026-07-06,分支 `feat/ios-client`)
- **现象(用户截图)**: 只在 `web-terminal` 跑了一个会话,但 "Active now" 同时显示 `web-terminal`/`Documents`/`yiukai` 三张卡,且父文件夹本身被列为项目。
- **根因(`src/http/projects.ts`)**: ① `belongsTo` 纯前缀匹配 → 会话按 cwd 归属到**每一个**祖先项目;② 历史合并(`mergeHistory`)把曾经跑过会话的 cwd(如 `~``~/Documents`)原样列为项目。

View File

@@ -1,4 +1,5 @@
import APIClient
import ClientTLS
import Foundation
import os
import SwiftUI
@@ -225,12 +226,19 @@ final class SessionThumbnailPipeline {
cache = SessionThumbnailCache(maxEntries: maxCacheEntries)
}
/// ephemeral URLSessionpreview
/// only T-iOS-19 RO GET
/// ephemeral URLSessionpreview
/// only T-iOS-19 RO GET C-iOS-2
/// mTLS nginx
/// provider keychain MEDIUM
/// =nil
static func live() -> SessionThumbnailPipeline {
SessionThumbnailPipeline(
let identityStore = KeychainClientIdentityStore()
let transport = URLSessionHTTPTransport(identityProvider: {
identityStore.loadedIdentityOrNil()
})
return SessionThumbnailPipeline(
loader: { request in
try await APIClient(endpoint: request.endpoint, http: liveTransport)
try await APIClient(endpoint: request.endpoint, http: transport)
.preview(id: request.sessionId)
},
renderer: { data, cols, rows in
@@ -239,8 +247,6 @@ final class SessionThumbnailPipeline {
)
}
private static let liveTransport = URLSessionHTTPTransport()
/// `.placeholder`
/// UI
func thumbnail(for request: SessionThumbnailRequest) async -> SessionThumbnailImage {

View File

@@ -0,0 +1,243 @@
import ClientTLS
import Foundation
import Observation
import SwiftUI
import UniformTypeIdentifiers
/// C-iOS-3 · Device-certificate install / rotation screen.
///
/// Flow: `.fileImporter([.pkcs12])` secure passphrase import (validates via
/// `SecPKCS12Import`) persist in the keychain show issuer CN + expiry, with
/// replace/rotate + remove. This is the in-app, app-sandboxed install path (a
/// `.p12` delivered via AirDrop/Files), NOT a configuration profile.
///
/// INTEGRATION NOTE: presenting this screen needs a one-line nav/Settings entry
/// ("Device certificate") in the app chrome (RootView / PairingScreen), which
/// live outside this task's ownership. The screen is fully self-contained so
/// that wiring is a single `NavigationLink { ClientCertScreen() }`.
struct ClientCertScreen: View {
@State private var model: ClientCertViewModel
init(model: ClientCertViewModel = ClientCertViewModel()) {
_model = State(initialValue: model)
}
var body: some View {
Form {
installedSection
importSection
if model.summary != nil {
removeSection
}
}
.navigationTitle(ClientCertCopy.title)
.fileImporter(
isPresented: $model.isPickingFile,
allowedContentTypes: [.pkcs12],
allowsMultipleSelection: false
) { result in
model.handleFileSelection(result)
}
.task { model.refresh() }
}
// MARK: - Sections
@ViewBuilder private var installedSection: some View {
Section(ClientCertCopy.installedHeader) {
if let summary = model.summary {
LabeledContent(ClientCertCopy.subject, value: summary.subjectCommonName ?? "")
LabeledContent(ClientCertCopy.issuer, value: summary.issuerCommonName ?? "")
LabeledContent(ClientCertCopy.expiry, value: model.expiryText(summary))
if summary.isExpired() {
Label(ClientCertCopy.expiredWarning, systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
}
} else {
Text(ClientCertCopy.noneInstalled).foregroundStyle(.secondary)
}
}
}
@ViewBuilder private var importSection: some View {
Section {
if let name = model.pendingFileName {
LabeledContent(ClientCertCopy.selectedFile, value: name)
SecureField(ClientCertCopy.passphrase, text: $model.passphrase)
.textContentType(.password)
.autocorrectionDisabled()
Button(ClientCertCopy.importAction) { model.importPending() }
.disabled(!model.canImport)
Button(ClientCertCopy.cancelAction, role: .cancel) { model.clearPending() }
} else {
Button {
model.beginPicking()
} label: {
Label(
model.summary == nil
? ClientCertCopy.chooseFile
: ClientCertCopy.replaceFile,
systemImage: "doc.badge.plus"
)
}
}
if let error = model.errorMessage {
Text(error).foregroundStyle(.red).font(.footnote)
}
} header: {
Text(model.summary == nil ? ClientCertCopy.importHeader : ClientCertCopy.rotateHeader)
} footer: {
Text(ClientCertCopy.importFooter)
}
}
@ViewBuilder private var removeSection: some View {
Section {
Button(ClientCertCopy.removeAction, role: .destructive) { model.remove() }
}
}
}
/// State machine for the install screen. Errors are mapped to actionable copy
/// and never swallowed; a failed import leaves any prior identity intact.
@MainActor
@Observable
final class ClientCertViewModel {
private(set) var summary: ClientCertificateSummary?
var isPickingFile = false
var passphrase = ""
private(set) var pendingData: Data?
private(set) var pendingFileName: String?
private(set) var errorMessage: String?
@ObservationIgnored private let store: any ClientIdentityStore
init(store: any ClientIdentityStore = KeychainClientIdentityStore()) {
self.store = store
}
var canImport: Bool { pendingData != nil && !passphrase.isEmpty }
func refresh() {
do {
summary = try store.loadSummary()
} catch {
summary = nil
errorMessage = ClientCertCopy.loadFailed
}
}
func beginPicking() {
errorMessage = nil
isPickingFile = true
}
func handleFileSelection(_ result: Result<[URL], Error>) {
switch result {
case .failure:
errorMessage = ClientCertCopy.fileReadFailed
case .success(let urls):
guard let url = urls.first else { return }
readFile(at: url)
}
}
private func readFile(at url: URL) {
let didAccess = url.startAccessingSecurityScopedResource()
defer { if didAccess { url.stopAccessingSecurityScopedResource() } }
do {
pendingData = try Data(contentsOf: url)
pendingFileName = url.lastPathComponent
passphrase = ""
errorMessage = nil
} catch {
pendingData = nil
pendingFileName = nil
errorMessage = ClientCertCopy.fileReadFailed
}
}
func importPending() {
guard let data = pendingData else { return }
do {
try store.save(p12Data: data, passphrase: passphrase)
clearPending()
refresh()
} catch {
errorMessage = Self.copy(for: error)
}
}
func clearPending() {
pendingData = nil
pendingFileName = nil
passphrase = ""
errorMessage = nil
}
func remove() {
do {
try store.remove()
summary = nil
errorMessage = nil
} catch {
errorMessage = ClientCertCopy.removeFailed
}
}
func expiryText(_ summary: ClientCertificateSummary) -> String {
guard let notAfter = summary.notAfter else { return "" }
return notAfter.formatted(date: .abbreviated, time: .omitted)
}
/// Map an import/storage error to install-UX copy (never swallowed).
private static func copy(for error: any Error) -> String {
switch error {
case PKCS12ImportError.wrongPassphrase:
return ClientCertCopy.errWrongPassphrase
case PKCS12ImportError.corruptFile:
return ClientCertCopy.errCorruptFile
case PKCS12ImportError.unsupported:
return ClientCertCopy.errUnsupported
case PKCS12ImportError.noIdentity:
return ClientCertCopy.errNoIdentity
case ClientIdentityStoreError.keychain, ClientIdentityStoreError.corruptStoredBlob:
return ClientCertCopy.errKeychain
default:
return ClientCertCopy.errUnknown
}
}
}
/// User-facing copy (Chinese, matching the rest of the app).
enum ClientCertCopy {
static let title = "设备证书"
static let installedHeader = "已安装证书"
static let subject = "设备 (CN)"
static let issuer = "签发 CA"
static let expiry = "有效期至"
static let expiredWarning = "证书已过期,请重新导入。"
static let noneInstalled = "尚未安装设备证书。"
static let importHeader = "导入证书"
static let rotateHeader = "替换证书"
static let chooseFile = "选择 .p12 文件"
static let replaceFile = "选择新的 .p12 文件"
static let selectedFile = "已选文件"
static let passphrase = "证书密码"
static let importAction = "导入"
static let cancelAction = "取消"
static let removeAction = "移除证书"
static let importFooter =
"证书用于连接你自己的隧道主机 (mTLS)。通过 AirDrop / 文件 把 .p12 传到本机后在此导入;证书与密码只保存在本设备钥匙串。"
static let errWrongPassphrase = "密码错误,请重试。"
static let errCorruptFile = "文件无法识别,请确认是有效的 .p12 证书。"
static let errUnsupported = "证书格式不受支持(请用 openssl pkcs12 -export -legacy 生成)。"
static let errNoIdentity = "该 .p12 不含身份(缺少私钥),无法用于客户端认证。"
static let errKeychain = "保存到钥匙串失败,请重试。"
static let errUnknown = "导入失败,请重试。"
static let loadFailed = "读取已安装证书失败。"
static let fileReadFailed = "无法读取所选文件。"
static let removeFailed = "移除证书失败,请重试。"
}

View File

@@ -23,6 +23,12 @@ struct SessionListScreen: View {
var onOpen: (SessionListViewModel.OpenRequest) -> Void = { _ in }
/// Host-switch header hook: "" entry (pairing sheet, T-iOS-15).
var onAddHost: () -> Void = {}
/// C-iOS-3 (HIGH reachability fix) · "" entry presents
/// `ClientCertScreen` (import / rotate the mTLS device cert). The device
/// cert is a global concern, but the toolbar host-menu is the app's only
/// settings-like surface and is present in every empty state, so this is the
/// nav idiom that makes the orphaned screen reachable.
var onDeviceCert: () -> Void = {}
/// T-iOS-28 (additive slot) · shared thumbnail pipeline: one cache + one
/// render-concurrency gate across ALL rows (scrolling must never spawn
/// unbounded offscreen terminals). `@State` keeps it stable across body
@@ -213,6 +219,12 @@ struct SessionListScreen: View {
} label: {
Label(ScreenCopy.addHost, systemImage: "plus")
}
Button {
onDeviceCert()
} label: {
Label(ScreenCopy.deviceCert, systemImage: "lock.shield")
}
.accessibilityIdentifier("sessions.deviceCert")
} label: {
Label(
viewModel.activeHost?.name ?? ScreenCopy.hostMenuFallback,
@@ -342,6 +354,7 @@ private enum ScreenCopy {
static let newSession = "新建会话"
static let kill = "结束"
static let addHost = "配对新主机"
static let deviceCert = "设备证书"
static let hostMenuFallback = "主机"
static let notPairedTitle = "还没有配对的主机"
static let notPairedHint = "先配对你电脑上的 web-terminal扫码或手输地址会话会出现在这里。"

View File

@@ -1,4 +1,5 @@
import APIClient
import ClientTLS
import Foundation
import HostRegistry
import Observation
@@ -116,10 +117,21 @@ final class PairingViewModel {
@ObservationIgnored private let store: any HostStore
@ObservationIgnored private let probe: Probe
/// C-iOS-3 · Whether a device client certificate is installed. Used to gate
/// the probe for tunnel hosts (mTLS-only). Injected so tests control it;
/// production reads the keychain. Defaulted so existing call sites compile.
@ObservationIgnored private let isDeviceCertInstalled: @Sendable () -> Bool
init(store: any HostStore, probe: @escaping Probe) {
init(
store: any HostStore,
probe: @escaping Probe,
isDeviceCertInstalled: @escaping @Sendable () -> Bool = {
KeychainClientIdentityStore().hasInstalledIdentity()
}
) {
self.store = store
self.probe = probe
self.isDeviceCertInstalled = isDeviceCertInstalled
}
// MARK: - Input boundaries (untrusted, validated via HostEndpoint)
@@ -205,10 +217,20 @@ final class PairingViewModel {
private func runProbe(for pending: PendingHost) async {
needsPublicRiskAcknowledgement = false
// C-iOS-3 · Tunnel hosts are mTLS-only: refuse to probe (which would
// fail at the TLS handshake) until a device certificate is installed.
// This is the single choke point both confirmConnect and retry funnel
// through, so the gate can't be bypassed via retry().
if Self.isTunnelHost(pending.endpoint), !isDeviceCertInstalled() {
phase = .failed(pending, FailureDisplay(
message: PairingCopy.deviceCertRequired, action: .retry
))
return
}
phase = .probing(pending)
switch await probe(pending.endpoint) {
case .failure(let error):
phase = .failed(pending, Self.display(for: error))
phase = .failed(pending, Self.display(for: error, endpoint: pending.endpoint))
case .success(let endpoint):
await storePairedHost(endpoint: endpoint, pending: pending)
}
@@ -239,6 +261,19 @@ final class PairingViewModel {
// MARK: - PairingError copy + action (task RED list, one case each)
/// C-iOS-3 · Host-aware display. nginx rejects an invalid/absent/revoked
/// client cert at the TLS layer; URLSession surfaces that as
/// secureConnectionFailed / connection-reset `PairingError.classify` maps
/// it to `.tlsFailure` ("server cert invalid"), which is the WRONG diagnosis
/// for an mTLS tunnel host. Re-map that one case to the device-cert copy;
/// everything else falls through to the per-case mapping.
static func display(for error: PairingError, endpoint: HostEndpoint) -> FailureDisplay {
if case .tlsFailure = error, isTunnelHost(endpoint) {
return FailureDisplay(message: PairingCopy.clientCertRejected, action: .retry)
}
return display(for: error)
}
static func display(for error: PairingError) -> FailureDisplay {
switch error {
case .localNetworkDenied:
@@ -270,6 +305,14 @@ final class PairingViewModel {
/// block regardless of scheme (§5.4 table: https is included in the
/// public-host confirm warning); otherwise https clears every notice.
static func warning(for endpoint: HostEndpoint) -> SecurityWarning {
// C-iOS-3 · A *.terminal.yaojia.wang tunnel host is gated by the device
// client certificate (mTLS), so the "anyone who can reach the port gets
// a shell" blocking warning is FALSE here and would only deter the
// intended flow. The real gate is the cert-install check in runProbe.
// Genuinely public NON-tunnel hosts still hit .publicHostBlocking below.
if isTunnelHost(endpoint) {
return .none
}
let hostClass = classifyHost(endpoint.baseURL.host ?? "")
if hostClass == .publicHost {
return .publicHostBlocking
@@ -353,8 +396,17 @@ final class PairingViewModel {
return octets
}
/// C-iOS-3 · A native mTLS reverse-tunnel host (`<name>.terminal.yaojia.wang`).
/// These reach a loopback base app through the VPS and are protected ONLY by
/// the device client certificate hence the softened warning + the
/// cert-install gate + the client-cert-rejected re-classification.
static func isTunnelHost(_ endpoint: HostEndpoint) -> Bool {
(endpoint.baseURL.host ?? "").lowercased().hasSuffix(tunnelZoneSuffix)
}
// MARK: - Named constants (no magic values, plan §4)
private static let tunnelZoneSuffix = ".terminal.yaojia.wang"
private static let schemeSeparator = "://"
private static let defaultManualScheme = "http://"
private static let httpsScheme = "https"
@@ -385,6 +437,13 @@ enum PairingCopy {
"TLS 连接失败:证书无效或不受信任。"
static let timeout =
"连接超时。请确认主机在线、与手机在同一网络后重试。"
/// C-iOS-3 · Tunnel host reached without a device certificate installed.
static let deviceCertRequired =
"请先安装本设备证书:到 设置 →「设备证书」导入 .p12 后,再连接该隧道主机。"
/// C-iOS-3 · nginx rejected the presented client certificate (invalid /
/// revoked). Surfaced in place of the mis-classified "server cert invalid".
static let clientCertRejected =
"本设备证书无效或已吊销,请重新导入。"
static func hostUnreachable(_ underlying: String) -> String {
"无法连接主机:\(underlying)"

View File

@@ -51,6 +51,9 @@ struct AdaptiveRootView: View {
) {
projectsSheet
}
.sheet(isPresented: $coordinator.isDeviceCertPresented) {
deviceCertSheet
}
}
// MARK: - Layout branch (the SOLE size-class consumer)
@@ -107,4 +110,20 @@ struct AdaptiveRootView: View {
}
}
}
// MARK: - Device certificate sheet (C-iOS-3, HIGH reachability fix)
/// NavigationStack`ClientCertScreen` `.navigationTitle`+
/// .p12 `ClientCertScreen`
/// `ClientCertViewModel`keychain store
@ViewBuilder private var deviceCertSheet: some View {
NavigationStack {
ClientCertScreen()
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(RootCopy.done) { coordinator.isDeviceCertPresented = false }
}
}
}
}
}

View File

@@ -31,6 +31,11 @@ final class AppCoordinator {
/// prefs
private(set) var projectsViewModel: ProjectsViewModel?
var isProjectsPresented = false
/// C-iOS-3 (HIGH reachability fix) · "" sheet (import / rotate the
/// mTLS device cert via `ClientCertScreen`). No VM state the screen owns
/// its own `ClientCertViewModel` over the keychain store; dismissal needs no
/// refresh because the transports resolve the identity lazily per connection.
var isDeviceCertPresented = false
let sessionList: SessionListViewModel
@ObservationIgnored let environment: AppEnvironment
@@ -108,6 +113,13 @@ final class AppCoordinator {
projectsViewModel = nil
}
// MARK: - Device certificate (C-iOS-3)
/// Toolbar host-menu / sheet
func presentDeviceCert() {
isDeviceCertPresented = true
}
/// "" sheet fresh spawn`attach(null, cwd)`+
/// attach `claude\r` engine attach-first
func openProject(_ request: ProjectOpenRequest) {

View File

@@ -1,4 +1,5 @@
import APIClient
import ClientTLS
import Foundation
import HostRegistry
import SessionCore
@@ -37,8 +38,20 @@ struct AppEnvironment: Sendable {
var unreadStore: any UnreadWatermarkStore = UserDefaultsUnreadWatermarkStore()
static func production() -> AppEnvironment {
let http = URLSessionHTTPTransport()
let termTransport = URLSessionTermTransport()
// C-iOS-2 (MEDIUM no-relaunch fix) · Resolve the installed device client
// identity LAZILY from the keychain on each connect/challenge, injected
// into BOTH transports + the probe as a provider. This way a certificate
// imported from the "" screen takes effect on the NEXT connection
// without relaunching a snapshot captured here would stay stale. A
// missing cert is the normal pre-install state ( nil); genuine faults
// are logged, not fatal (`loadedIdentityOrNil`). mTLS challenges only
// fire for tunnel hosts, so a `nil` result is inert for local hosts.
let identityStore = KeychainClientIdentityStore()
let identityProvider: @Sendable () -> ClientIdentity? = {
identityStore.loadedIdentityOrNil()
}
let http = URLSessionHTTPTransport(identityProvider: identityProvider)
let termTransport = URLSessionTermTransport(identityProvider: identityProvider)
return AppEnvironment(
hostStore: KeychainHostStore(),
lastSessionStore: UserDefaultsLastSessionStore(),

View File

@@ -74,7 +74,8 @@ struct StackRootView: View {
SessionListScreen(
viewModel: coordinator.sessionList,
onOpen: { coordinator.open($0) },
onAddHost: { coordinator.presentAddHost() }
onAddHost: { coordinator.presentAddHost() },
onDeviceCert: { coordinator.presentDeviceCert() }
)
.safeAreaInset(edge: .bottom) { continueLastBanner }
// / DS reduceMotion
@@ -178,4 +179,5 @@ struct ProjectsToolbarItem: ToolbarContent {
enum RootCopy {
static let continueLast = "继续上次会话"
static let projects = "项目"
static let done = "完成"
}

View File

@@ -39,7 +39,8 @@ struct SplitRootView: View {
// selectSidebarItem
coordinator.selectSidebarItem(sidebarItem(for: request))
},
onAddHost: { coordinator.presentAddHost() }
onAddHost: { coordinator.presentAddHost() },
onDeviceCert: { coordinator.presentDeviceCert() }
)
.safeAreaInset(edge: .bottom) { continueLastBanner }
.animation(

View File

@@ -1,3 +1,4 @@
import ClientTLS
import Foundation
import WireProtocol
@@ -9,14 +10,39 @@ import WireProtocol
/// bypass that single audited point (review CRITICAL).
struct URLSessionHTTPTransport: HTTPTransport {
private let session: URLSession
/// Strong reference to the mTLS delegate. URLSession retains its delegate
/// until invalidated, but this ephemeral session is never explicitly
/// invalidated, so holding it here documents the ownership and keeps the
/// transport a self-contained value.
private let tlsDelegate: LazyClientTLSSessionDelegate
/// Default is EPHEMERAL, not `.shared` (T-iOS-19 finding): RO GET bodies
/// include `/live-sessions/:id/preview` raw terminal ring-buffer bytes
/// that may contain printed secrets and `.shared`'s default URLCache
/// writes responses to disk. Ephemeral keeps them memory-only, matching
/// the WS transport and the privacy-shade posture.
init(session: URLSession = URLSession(configuration: .ephemeral)) {
self.session = session
/// Fixed-identity convenience (snapshot callers / tests): wraps a constant
/// provider, so behaviour is identical to capturing the identity directly.
init(identity: ClientIdentity? = nil) {
self.init(identityProvider: { identity })
}
/// C-iOS-2 (MEDIUM no-relaunch fix) · The session's mTLS delegate resolves
/// the device identity LAZILY, per client-certificate challenge, from
/// `identityProvider`. A `ClientCertificate` challenge from a tunneled host
/// is thus answered with whatever cert is installed AT CHALLENGE TIME so a
/// certificate imported mid-run is presented on the NEXT TLS handshake
/// without an app relaunch (a snapshot captured here would stay stale). The
/// session itself stays a single long-lived value (no per-request churn).
/// Local http/https hosts never issue that challenge, so the provider is
/// never even consulted for them (and a `nil` result is inert regardless).
///
/// EPHEMERAL, not `.shared` (T-iOS-19 finding): RO GET bodies include
/// `/live-sessions/:id/preview` raw terminal ring-buffer bytes that may
/// contain printed secrets and `.shared`'s default URLCache writes
/// responses to disk. Ephemeral keeps them memory-only, matching the WS
/// transport and the privacy-shade posture.
init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
let delegate = LazyClientTLSSessionDelegate(identityProvider: identityProvider)
self.tlsDelegate = delegate
self.session = URLSession(
configuration: .ephemeral, delegate: delegate, delegateQueue: nil
)
}
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
@@ -29,3 +55,40 @@ struct URLSessionHTTPTransport: HTTPTransport {
return (data, httpResponse)
}
}
/// C-iOS-2 (MEDIUM no-relaunch fix) · Session-level mTLS delegate that resolves
/// the device identity FRESH per client-certificate challenge the App-layer
/// twin of ClientTLS's fixed-identity `ClientTLSSessionDelegate` (which captures
/// the identity once). The provider is only consulted for a `ClientCertificate`
/// challenge, so a `ServerTrust` challenge never triggers a keychain read; the
/// pure decision itself is delegated to the shared `MutualTLSChallengeResponder`
/// (single truth table, unit-tested in ClientTLS).
///
/// `@unchecked Sendable`: URLSession retains its delegate and invokes it from
/// arbitrary queues; every stored field is an immutable `let` over a `@Sendable`
/// value (the provider is `@Sendable`, the responder is stateless).
private final class LazyClientTLSSessionDelegate:
NSObject, URLSessionDelegate, @unchecked Sendable {
private let identityProvider: @Sendable () -> ClientIdentity?
private let responder = MutualTLSChallengeResponder()
init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
self.identityProvider = identityProvider
super.init()
}
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
// Only a client-certificate challenge needs the identity; resolving it
// for a server-trust challenge would do a needless keychain read/import
// on every HTTPS handshake.
let isClientCert = challenge.protectionSpace.authenticationMethod
== NSURLAuthenticationMethodClientCertificate
let identity = isClientCert ? identityProvider() : nil
let resolution = responder.resolve(challenge, identity: identity)
completionHandler(resolution.disposition, resolution.credential)
}
}

View File

@@ -0,0 +1,22 @@
// swift-tools-version: 6.0
// C-iOS-1 · Device client-certificate (mutual-TLS) leaf package.
//
// Standalone, no local-package dependencies imports ONLY Security/Foundation.
// It is a downward leaf: SessionCore and the App target may depend on it, it
// depends on nothing in this workspace. This keeps the pure, unit-testable
// mTLS logic (identity wrapper, PKCS#12 import, challenge responder) isolated
// from the WS/HTTP transport plumbing that consumes it.
import PackageDescription
let package = Package(
name: "ClientTLS",
platforms: [.iOS(.v17), .macOS(.v14)],
products: [
.library(name: "ClientTLS", targets: ["ClientTLS"]),
],
targets: [
.target(name: "ClientTLS"),
.testTarget(name: "ClientTLSTests", dependencies: ["ClientTLS"]),
],
swiftLanguageModes: [.v6]
)

View File

@@ -0,0 +1,188 @@
import Foundation
import Security
/// C-iOS-3 · Display summary of a device certificate, shown on the install /
/// rotation screen so the user can confirm *which* cert is active and *when*
/// it expires before relying on it.
public struct ClientCertificateSummary: Equatable, Sendable {
/// Subject common name the device/leaf CN (e.g. `t1-iphone`).
public let subjectCommonName: String?
/// Issuer common name the device-CA CN (e.g. `webterm-device-ca`).
public let issuerCommonName: String?
/// Not-after date; `nil` if it could not be parsed.
public let notAfter: Date?
public init(subjectCommonName: String?, issuerCommonName: String?, notAfter: Date?) {
self.subjectCommonName = subjectCommonName
self.issuerCommonName = issuerCommonName
self.notAfter = notAfter
}
/// Expired relative to `now` (defaults to the current instant). Unknown
/// expiry is treated as NOT expired (fail-open for display only the TLS
/// stack, not this label, is the real gate).
public func isExpired(asOf now: Date = Date()) -> Bool {
guard let notAfter else { return false }
return notAfter < now
}
}
/// Reads the display fields off a `SecCertificate`.
///
/// Implemented as a minimal X.509 DER walk over `SecCertificateCopyData` rather
/// than `SecCertificateCopyValues`, because that API and its `kSecOID*` /
/// `kSecPropertyKey*` constants are **macOS-only** unavailable on iOS, which
/// is the real deployment target. The DER walk is identical on both platforms
/// and unit-testable against the fixture. Any parse miss degrades to `nil`
/// fields (the summary is display-only; the TLS stack is the gate).
enum CertificateInspector {
static func summary(of certificate: SecCertificate) -> ClientCertificateSummary {
let der = [UInt8](SecCertificateCopyData(certificate) as Data)
let fields = X509Fields.parse(der: der)
return ClientCertificateSummary(
subjectCommonName: fields?.subjectCommonName,
issuerCommonName: fields?.issuerCommonName,
notAfter: fields?.notAfter
)
}
}
// MARK: - Minimal ASN.1 DER reader
/// One tag-length-value element, as byte ranges into the source buffer.
private struct DERElement {
let tag: UInt8
let valueStart: Int
let valueEnd: Int
}
private enum DER {
/// DER tags used here.
static let sequence: UInt8 = 0x30
static let set: UInt8 = 0x31
static let oid: UInt8 = 0x06
static let contextTag0: UInt8 = 0xA0 // [0] EXPLICIT (X.509 version)
static let utcTime: UInt8 = 0x17
static let generalizedTime: UInt8 = 0x18
/// Read a single TLV at `start`. Returns the element and the index just
/// past it, or `nil` on any malformed length/overrun (defensive: the cert
/// bytes come from the system but are still parsed as untrusted input).
static func read(_ bytes: [UInt8], at start: Int) -> DERElement? {
guard start >= 0, start + 1 < bytes.count else { return nil }
let tag = bytes[start]
var index = start + 1
let firstLengthByte = bytes[index]
index += 1
var length = 0
if firstLengthByte & 0x80 == 0 {
length = Int(firstLengthByte)
} else {
let byteCount = Int(firstLengthByte & 0x7F)
guard byteCount > 0, byteCount <= 4, index + byteCount <= bytes.count else {
return nil
}
for _ in 0..<byteCount {
length = (length << 8) | Int(bytes[index])
index += 1
}
}
let valueEnd = index + length
guard length >= 0, valueEnd <= bytes.count else { return nil }
return DERElement(tag: tag, valueStart: index, valueEnd: valueEnd)
}
/// Split a constructed value `[start, end)` into its immediate children.
static func children(_ bytes: [UInt8], from start: Int, to end: Int) -> [DERElement] {
var elements: [DERElement] = []
var index = start
while index < end, let element = read(bytes, at: index) {
elements.append(element)
index = element.valueEnd
}
return elements
}
}
// MARK: - X.509 field extraction
private struct X509Fields {
let subjectCommonName: String?
let issuerCommonName: String?
let notAfter: Date?
/// CommonName attribute OID `2.5.4.3` DER content bytes `55 04 03`.
private static let commonNameOID: [UInt8] = [0x55, 0x04, 0x03]
/// Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signature }
/// tbsCertificate ::= SEQUENCE { [0] version?, serialNumber, signature,
/// issuer Name, validity, subject Name, subjectPublicKeyInfo, ... }
static func parse(der bytes: [UInt8]) -> X509Fields? {
guard let certificate = DER.read(bytes, at: 0), certificate.tag == DER.sequence else {
return nil
}
let certChildren = DER.children(
bytes, from: certificate.valueStart, to: certificate.valueEnd
)
guard let tbs = certChildren.first, tbs.tag == DER.sequence else { return nil }
var tbsChildren = DER.children(bytes, from: tbs.valueStart, to: tbs.valueEnd)
// Optional EXPLICIT [0] version drop it so the fixed fields align.
if let first = tbsChildren.first, first.tag == DER.contextTag0 {
tbsChildren.removeFirst()
}
// Fixed order after (optional) version: serialNumber, signature,
// issuer, validity, subject, ...
guard tbsChildren.count >= 5 else { return nil }
let issuer = tbsChildren[2]
let validity = tbsChildren[3]
let subject = tbsChildren[4]
return X509Fields(
subjectCommonName: commonName(bytes, in: subject),
issuerCommonName: commonName(bytes, in: issuer),
notAfter: notAfterDate(bytes, in: validity)
)
}
/// Name ::= SEQUENCE OF RDN(SET) OF AttributeTypeAndValue(SEQ{OID, value}).
/// Returns the first CN value found.
private static func commonName(_ bytes: [UInt8], in name: DERElement) -> String? {
for rdn in DER.children(bytes, from: name.valueStart, to: name.valueEnd)
where rdn.tag == DER.set {
for atv in DER.children(bytes, from: rdn.valueStart, to: rdn.valueEnd)
where atv.tag == DER.sequence {
let parts = DER.children(bytes, from: atv.valueStart, to: atv.valueEnd)
guard parts.count >= 2, parts[0].tag == DER.oid else { continue }
let oid = Array(bytes[parts[0].valueStart..<parts[0].valueEnd])
if oid == commonNameOID {
let value = Array(bytes[parts[1].valueStart..<parts[1].valueEnd])
// PrintableString / UTF8String both decode as UTF-8.
return String(bytes: value, encoding: .utf8)
}
}
}
return nil
}
/// Validity ::= SEQUENCE { notBefore Time, notAfter Time } the 2nd child.
private static func notAfterDate(_ bytes: [UInt8], in validity: DERElement) -> Date? {
let times = DER.children(bytes, from: validity.valueStart, to: validity.valueEnd)
guard times.count >= 2 else { return nil }
let notAfter = times[1]
let value = Array(bytes[notAfter.valueStart..<notAfter.valueEnd])
guard let text = String(bytes: value, encoding: .ascii) else { return nil }
return parseTime(text, tag: notAfter.tag)
}
private static func parseTime(_ text: String, tag: UInt8) -> Date? {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(identifier: "UTC")
// UTCTime: YYMMDDHHMMSSZ (used for years < 2050); GeneralizedTime:
// YYYYMMDDHHMMSSZ. The 2-digit-year window covers the relevant range
// (device certs live in the 2020s-2040s).
formatter.dateFormat = tag == DER.utcTime ? "yyMMddHHmmss'Z'" : "yyyyMMddHHmmss'Z'"
return formatter.date(from: text)
}
}

View File

@@ -0,0 +1,55 @@
import Foundation
import Security
/// C-iOS-1 · An imported device client identity: the `SecIdentity` (leaf
/// certificate + its private key) plus the issuer chain that accompanies it in
/// the TLS handshake.
///
/// `@unchecked Sendable`: `SecIdentity` / `SecCertificate` are CoreFoundation
/// handles that are immutable once imported and thread-safe to read; this value
/// only ever holds finished imports and never mutates them. Marking it lets the
/// identity flow into the `@unchecked Sendable` WS connection and the URLSession
/// delegates that answer client-certificate challenges.
public struct ClientIdentity: @unchecked Sendable {
/// The leaf certificate + private key used to authenticate to the server.
public let secIdentity: SecIdentity
/// Issuer certificates to present alongside the leaf (the CA chain, leaf
/// excluded). May be empty when the trust anchor is already pinned server
/// side (nginx `ssl_client_certificate` = the device-CA) the leaf alone
/// then verifies at `ssl_verify_depth 1`.
public let issuerCertificates: [SecCertificate]
public init(secIdentity: SecIdentity, issuerCertificates: [SecCertificate] = []) {
self.secIdentity = secIdentity
self.issuerCertificates = issuerCertificates
}
/// The `URLCredential` handed back to a `ClientCertificate` auth challenge.
///
/// `.forSession` (not `.permanent`) per plan §C-iOS-1: the identity already
/// lives in the app's keychain item persisting the credential in the
/// shared URL credential store would be a second, unmanaged copy.
public func urlCredential(
persistence: URLCredential.Persistence = .forSession
) -> URLCredential {
URLCredential(
identity: secIdentity,
certificates: issuerCertificates.isEmpty ? nil : issuerCertificates,
persistence: persistence
)
}
/// The leaf `SecCertificate` backing this identity (for display / summary).
public func leafCertificate() -> SecCertificate? {
var certificate: SecCertificate?
let status = SecIdentityCopyCertificate(secIdentity, &certificate)
return status == errSecSuccess ? certificate : nil
}
/// Human-readable summary of the leaf certificate (subject CN, issuer CN,
/// expiry) for the install/rotation UI. `nil` only if the leaf can't be
/// read (should never happen for a valid import).
public func summary() -> ClientCertificateSummary? {
leafCertificate().map(CertificateInspector.summary(of:))
}
}

View File

@@ -0,0 +1,36 @@
import Foundation
/// C-iOS-2 · Reusable `NSObject` URLSession delegate that answers
/// **session-level** auth challenges by delegating to `MutualTLSChallengeResponder`.
///
/// This is the seam for the HTTP transport (`session.data(for:)` surfaces
/// challenges through `urlSession(_:didReceive:completionHandler:)`). The WS
/// transport can't reuse it its connection object is already the session's
/// delegate and needs the **task-level** callback so that one implements the
/// task-level method itself against the same responder.
///
/// `@unchecked Sendable`: URLSession retains its delegate and invokes it from
/// arbitrary queues; every stored field is an immutable `let` over thread-safe
/// values (`ClientIdentity` is `@unchecked Sendable`, the responder is stateless).
public final class ClientTLSSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
private let identity: ClientIdentity?
private let responder: MutualTLSChallengeResponder
public init(
identity: ClientIdentity?,
responder: MutualTLSChallengeResponder = MutualTLSChallengeResponder()
) {
self.identity = identity
self.responder = responder
super.init()
}
public func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
let resolution = responder.resolve(challenge, identity: identity)
completionHandler(resolution.disposition, resolution.credential)
}
}

View File

@@ -0,0 +1,200 @@
import Foundation
import os
import Security
/// Persists the device identity so it survives relaunch. The raw `.p12` bytes
/// **and** its passphrase are stored together (the passphrase is required to
/// re-import via `SecPKCS12Import` at every launch), then re-imported on load.
public protocol ClientIdentityStore: Sendable {
/// Validate (`SecPKCS12Import`) then persist the `.p12` + passphrase.
/// Throws `PKCS12ImportError` on a bad passphrase / corrupt file (nothing is
/// persisted in that case) and `ClientIdentityStoreError` on a storage fault.
func save(p12Data: Data, passphrase: String) throws
/// Re-import and return the stored identity; `nil` if none is installed.
func loadIdentity() throws -> ClientIdentity?
/// Display summary of the stored certificate; `nil` if none is installed.
func loadSummary() throws -> ClientCertificateSummary?
/// Delete the stored identity (rotation / removal). Idempotent.
func remove() throws
/// Cheap existence check for gating (does NOT re-import).
func hasInstalledIdentity() -> Bool
}
public enum ClientIdentityStoreError: Error, Equatable, Sendable {
/// A Keychain `SecItem*` call failed with this `OSStatus`.
case keychain(OSStatus)
/// The stored blob was present but could not be decoded.
case corruptStoredBlob
}
public extension ClientIdentityStore {
/// Convenience for composition roots: load the identity, logging and
/// swallowing errors into `nil`. A missing cert is the normal pre-install
/// state; a genuine fault must not crash launch, but is logged (never
/// silently dropped).
func loadedIdentityOrNil() -> ClientIdentity? {
do {
return try loadIdentity()
} catch {
ClientTLSLog.identity.error(
"loadIdentity failed: \(String(describing: error), privacy: .public)"
)
return nil
}
}
}
/// The stored payload `.p12` bytes plus the passphrase needed to re-import.
private struct StoredP12Blob: Codable {
let p12: Data
let passphrase: String
}
/// Keychain-backed store: one `kSecClassGenericPassword` item holding the
/// JSON-encoded `StoredP12Blob` in `kSecValueData`, protected with
/// `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` (available after the first
/// unlock post-boot, never migrates off this device).
public struct KeychainClientIdentityStore: ClientIdentityStore {
public static let defaultService = "com.yaojia.webterm.clienttls"
public static let defaultAccount = "device-identity"
private let service: String
private let account: String
public init(
service: String = defaultService, account: String = defaultAccount
) {
self.service = service
self.account = account
}
public func save(p12Data: Data, passphrase: String) throws {
// Validate BEFORE persisting a wrong passphrase / corrupt file must
// surface to the install UI and leave any prior identity untouched.
_ = try PKCS12Importer.importIdentity(data: p12Data, passphrase: passphrase)
let blob = try encode(StoredP12Blob(p12: p12Data, passphrase: passphrase))
try writeItem(blob)
}
public func loadIdentity() throws -> ClientIdentity? {
guard let blob = try readBlob() else { return nil }
return try PKCS12Importer.importIdentity(
data: blob.p12, passphrase: blob.passphrase
)
}
public func loadSummary() throws -> ClientCertificateSummary? {
try loadIdentity()?.summary()
}
public func remove() throws {
let status = SecItemDelete(baseQuery() as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw ClientIdentityStoreError.keychain(status)
}
}
public func hasInstalledIdentity() -> Bool {
var query = baseQuery()
query[kSecReturnData as String] = false
query[kSecMatchLimit as String] = kSecMatchLimitOne
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
}
// MARK: - Keychain plumbing
private func baseQuery() -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
}
private func writeItem(_ data: Data) throws {
// Delete-then-add keeps the item's protection class deterministic
// (SecItemUpdate can't change kSecAttrAccessible in place).
let deleteStatus = SecItemDelete(baseQuery() as CFDictionary)
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
throw ClientIdentityStoreError.keychain(deleteStatus)
}
var attributes = baseQuery()
attributes[kSecValueData as String] = data
attributes[kSecAttrAccessible as String] =
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let addStatus = SecItemAdd(attributes as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw ClientIdentityStoreError.keychain(addStatus)
}
}
private func readBlob() throws -> StoredP12Blob? {
var query = baseQuery()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let data = result as? Data else {
throw ClientIdentityStoreError.keychain(status)
}
do {
return try JSONDecoder().decode(StoredP12Blob.self, from: data)
} catch {
throw ClientIdentityStoreError.corruptStoredBlob
}
}
private func encode(_ blob: StoredP12Blob) throws -> Data {
do {
return try JSONEncoder().encode(blob)
} catch {
throw ClientIdentityStoreError.corruptStoredBlob
}
}
}
/// In-memory store for previews and unit tests: same import/summary code path as
/// the keychain store (so the roundtrip is exercised) without any keychain
/// entitlement. `@unchecked Sendable` mutable blob guarded by a lock.
public final class InMemoryClientIdentityStore: ClientIdentityStore, @unchecked Sendable {
private let lock = NSLock()
private var blob: StoredP12BlobBox?
/// Boxed so the private `StoredP12Blob` type stays file-private above; this
/// mirror keeps the two bytes+passphrase without exposing the Codable type.
private struct StoredP12BlobBox {
let p12: Data
let passphrase: String
}
public init() {}
public func save(p12Data: Data, passphrase: String) throws {
_ = try PKCS12Importer.importIdentity(data: p12Data, passphrase: passphrase)
lock.withLock { blob = StoredP12BlobBox(p12: p12Data, passphrase: passphrase) }
}
public func loadIdentity() throws -> ClientIdentity? {
guard let stored = lock.withLock({ blob }) else { return nil }
return try PKCS12Importer.importIdentity(
data: stored.p12, passphrase: stored.passphrase
)
}
public func loadSummary() throws -> ClientCertificateSummary? {
try loadIdentity()?.summary()
}
public func remove() throws {
lock.withLock { blob = nil }
}
public func hasInstalledIdentity() -> Bool {
lock.withLock { blob != nil }
}
}
enum ClientTLSLog {
static let identity = Logger(subsystem: "com.yaojia.webterm", category: "client-tls")
}

View File

@@ -0,0 +1,73 @@
import Foundation
/// C-iOS-1 · The pure, synchronous decision at the heart of mutual TLS.
///
/// It maps a URLSession auth challenge (+ the optionally-installed device
/// identity) to a disposition and credential. Deliberately free of any
/// URLSession / socket state so the full truth table is unit-testable without a
/// live connection the transports (WS task delegate, HTTP session delegate)
/// are thin adapters that call `resolve` and forward its result to the
/// completion handler.
///
/// Truth table (plan §C-iOS-1):
/// a. `ClientCertificate` + identity present `.useCredential` with the
/// identity's `URLCredential`.
/// b. `ClientCertificate` + no identity `.cancelAuthenticationChallenge`
/// (a clean, classifiable failure never a silent no-cert continue).
/// c. `ServerTrust` `.performDefaultHandling`
/// (the LE wildcard is validated by the system trust store).
/// d. anything else `.performDefaultHandling`.
public struct MutualTLSChallengeResponder: Sendable {
/// The responder's decision. Not `Sendable` (it may carry a `URLCredential`,
/// which isn't) it is produced and consumed synchronously on the delegate
/// queue, never sent across isolation domains.
public struct Resolution {
public let disposition: URLSession.AuthChallengeDisposition
public let credential: URLCredential?
public init(
disposition: URLSession.AuthChallengeDisposition,
credential: URLCredential? = nil
) {
self.disposition = disposition
self.credential = credential
}
}
public init() {}
/// Resolve a full `URLAuthenticationChallenge` by dispatching on its
/// authentication method.
public func resolve(
_ challenge: URLAuthenticationChallenge, identity: ClientIdentity?
) -> Resolution {
resolve(
authenticationMethod: challenge.protectionSpace.authenticationMethod,
identity: identity
)
}
/// Method-keyed core (challenge-free) the directly-tested pure function.
public func resolve(
authenticationMethod: String, identity: ClientIdentity?
) -> Resolution {
switch authenticationMethod {
case NSURLAuthenticationMethodClientCertificate:
guard let identity else {
// (b) No installed identity: cancel so the failure surfaces as a
// classifiable client-cert error rather than a bare TLS reset.
return Resolution(disposition: .cancelAuthenticationChallenge)
}
// (a) Present the device certificate for this session.
return Resolution(
disposition: .useCredential, credential: identity.urlCredential()
)
case NSURLAuthenticationMethodServerTrust:
// (c) System-trusted LE wildcard default evaluation.
return Resolution(disposition: .performDefaultHandling)
default:
// (d) Basic/Digest/NTLM/etc. not used by this app; default.
return Resolution(disposition: .performDefaultHandling)
}
}
}

View File

@@ -0,0 +1,89 @@
import Foundation
import Security
/// Failure modes of a PKCS#12 import, each mapped to actionable install-UX copy
/// (C-iOS-3). The three plan-named `OSStatus` mappings are honored:
/// `errSecAuthFailed .wrongPassphrase`, `errSecDecode .corruptFile`, and any
/// other non-success status (unsupported/unknown format the plan's "errSecPkg"
/// bucket) `.unsupported`.
public enum PKCS12ImportError: Error, Equatable, Sendable {
/// Wrong passphrase (`errSecAuthFailed`).
case wrongPassphrase
/// Not a decodable PKCS#12 blob (`errSecDecode`) truncated / not a `.p12`.
case corruptFile
/// Decoded, but the format/algorithms aren't importable on this OS
/// (any other non-success `OSStatus`). Retains the raw status for logs.
case unsupported(OSStatus)
/// Import succeeded but carried no `SecIdentity` (e.g. a certs-only `.p12`).
case noIdentity
public static func == (lhs: PKCS12ImportError, rhs: PKCS12ImportError) -> Bool {
switch (lhs, rhs) {
case (.wrongPassphrase, .wrongPassphrase),
(.corruptFile, .corruptFile),
(.noIdentity, .noIdentity):
return true
case let (.unsupported(a), .unsupported(b)):
return a == b
default:
return false
}
}
}
/// Imports a `.p12` into a `ClientIdentity` via `SecPKCS12Import`.
///
/// Memory-only: `SecPKCS12Import` returns the identity/chain as CoreFoundation
/// handles without writing to the keychain, so callers control persistence
/// (see `KeychainClientIdentityStore`).
public enum PKCS12Importer {
public static func importIdentity(
data: Data, passphrase: String
) throws -> ClientIdentity {
let options = [kSecImportExportPassphrase as String: passphrase] as CFDictionary
var rawItems: CFArray?
let status = SecPKCS12Import(data as CFData, options, &rawItems)
switch status {
case errSecSuccess:
break
case errSecAuthFailed:
throw PKCS12ImportError.wrongPassphrase
case errSecDecode:
throw PKCS12ImportError.corruptFile
default:
// Covers unsupported formats/algorithms and any other failure
// the plan's `errSecPkg .unsupported` mapping (that symbol does
// not exist in the SDK; the status is preserved for diagnostics).
throw PKCS12ImportError.unsupported(status)
}
guard let items = rawItems as? [[String: Any]], let first = items.first,
let identityValue = first[kSecImportItemIdentity as String]
else {
throw PKCS12ImportError.noIdentity
}
// Force-cast is safe: `kSecImportItemIdentity` is always a SecIdentity.
let secIdentity = identityValue as! SecIdentity
let chain = (first[kSecImportItemCertChain as String] as? [SecCertificate]) ?? []
return ClientIdentity(
secIdentity: secIdentity,
issuerCertificates: issuerChain(from: chain, identity: secIdentity)
)
}
/// The chain from `SecPKCS12Import` includes the leaf at index 0; the
/// `URLCredential` must carry only the *issuer* certs (Apple: do not repeat
/// the identity's own cert). Drops whichever chain entry DER-matches the
/// identity's leaf.
private static func issuerChain(
from chain: [SecCertificate], identity: SecIdentity
) -> [SecCertificate] {
var leaf: SecCertificate?
SecIdentityCopyCertificate(identity, &leaf)
guard let leafData = leaf.map({ SecCertificateCopyData($0) as Data }) else {
return chain
}
return chain.filter { SecCertificateCopyData($0) as Data != leafData }
}
}

View File

@@ -0,0 +1,62 @@
import Foundation
import Testing
@testable import ClientTLS
// C-iOS-1 · Store roundtrip via the InMemory store (same import/summary code
// path as the keychain store, minus the entitlement the keychain variant is
// covered by on-device/simulator tests, mirroring KeychainHostStoreLiveTests).
@Test("save → loadIdentity roundtrips and hasInstalledIdentity flips")
func storeRoundtrip() throws {
// Arrange
let store = InMemoryClientIdentityStore()
#expect(store.hasInstalledIdentity() == false)
#expect(try store.loadIdentity() == nil)
// Act
try store.save(
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
)
// Assert
#expect(store.hasInstalledIdentity() == true)
let identity = try #require(try store.loadIdentity())
#expect(identity.summary()?.subjectCommonName == ClientTLSFixtures.leafCommonName)
let summary = try #require(try store.loadSummary())
#expect(summary.issuerCommonName == ClientTLSFixtures.issuerCommonName)
}
@Test("save validates the passphrase and leaves prior state untouched on failure")
func storeSaveValidatesBeforePersisting() {
// Arrange
let store = InMemoryClientIdentityStore()
// Act / Assert a wrong passphrase must surface, not persist.
#expect(throws: PKCS12ImportError.wrongPassphrase) {
try store.save(p12Data: ClientTLSFixtures.deviceP12Data, passphrase: "wrong")
}
#expect(store.hasInstalledIdentity() == false)
}
@Test("remove clears the stored identity")
func storeRemove() throws {
// Arrange
let store = InMemoryClientIdentityStore()
try store.save(
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
)
#expect(store.hasInstalledIdentity() == true)
// Act
try store.remove()
// Assert
#expect(store.hasInstalledIdentity() == false)
#expect(try store.loadIdentity() == nil)
}
@Test("loadedIdentityOrNil returns nil (not a throw) when nothing is installed")
func loadedIdentityOrNilEmpty() {
let store = InMemoryClientIdentityStore()
#expect(store.loadedIdentityOrNil() == nil)
}

View File

@@ -0,0 +1,33 @@
import Foundation
/// Embedded test fixture: a real PKCS#12 generated at authoring time with
/// OpenSSL (EC P-256 self-signed device-CA EC P-256 leaf, `EKU=clientAuth`,
/// exported `-legacy` for iOS/Android import parity mirrors
/// `deploy/scripts/gen-device-ca.sh` / `issue-device-cert.sh`).
///
/// Embedded as base64 rather than an SPM resource so the MUST-PASS package test
/// runs headless with no `Bundle.module` resource wiring. Regenerate with:
/// openssl ecparam -name prime256v1 -genkey -noout -out ca.key.pem
/// openssl req -x509 -new -key ca.key.pem -sha256 -days 3650 \
/// -subj "/CN=webterm-device-ca-fixture" \
/// -addext "basicConstraints=critical,CA:TRUE" \
/// -addext "keyUsage=critical,keyCertSign,cRLSign" -out ca.cert.pem
/// openssl ecparam -name prime256v1 -genkey -noout -out leaf.key.pem
/// openssl req -new -key leaf.key.pem -subj "/CN=device-fixture" -out leaf.csr.pem
/// openssl x509 -req -in leaf.csr.pem -CA ca.cert.pem -CAkey ca.key.pem \
/// -CAcreateserial -days 825 -sha256 -extfile leaf.ext -out leaf.cert.pem
/// openssl pkcs12 -export -legacy -inkey leaf.key.pem -in leaf.cert.pem \
/// -certfile ca.cert.pem -passout pass:clienttls-test-pass \
/// -name device-fixture -out device.p12 ; base64 device.p12
enum ClientTLSFixtures {
static let passphrase = "clienttls-test-pass"
static let leafCommonName = "device-fixture"
static let issuerCommonName = "webterm-device-ca-fixture"
static var deviceP12Data: Data {
Data(base64Encoded: deviceP12Base64)!
}
// swiftlint:disable:next line_length
static let deviceP12Base64 = "MIIF8wIBAzCCBbkGCSqGSIb3DQEHAaCCBaoEggWmMIIFojCCBGcGCSqGSIb3DQEHBqCCBFgwggRUAgEAMIIETQYJKoZIhvcNAQcBMBwGCiqGSIb3DQEMAQYwDgQIKBUqQZQxk0wCAggAgIIEIBIkpttDOWO6edQjmr9V3ASfsuc0m+Wdl8//Yrt5FPgz5HOhYZKhOUXhBLWyGXPnTx8fBrC1Yk4YmXwQisHT/4PsfvwIAgF9PBFo3UNwsQLATWHrplUF1rTO9uB5Luju306Ox9QYB+VP64BOFxdixtvhjZF8LemfJ6bV/9DWmXOk5UoGdd0c5/6WeYUTfr1aFG/TJo+GOHa5CD5fW5Mr4SejYKNcvvT2Fki5kCKAQRbhJb8W7+RzcmYoF6ECzCNeSnDNenpZm2xowpdGP/6d3U7MbrXj4bmAtQYljAnA391atw/bm2T9d8Q6CPx5QD+WqMNdKN6tYV9OvDB6XB+VPimVMd0GCNYaIKazHj0ohNYqvEYDjSaYgbBvLHJyJRqnumyjwMz4o26NoTGm1m/U28pUGUJL3njYhuyxKilat85oXT3YO4x16bCeF7fmuGVkakeZjrxvxAKap8rD5yPaDd533Va9+xg1byIj7h01Q5tiOXO3sqkEYB1XI9/n78Et5eZItMYEH53v4uE9NRWZmetA7TQ5uRon4EK1ajKIJaQi/6lGdCAzLf5tysZ7A8vfJPJeBRFI2a9QYQzcyL8sZWEfAhJszRo59g6LWZn87DXL6EFEP/UvV0tDy6kPz/OzUmDGMrWNbeCX/5zI/xaFaf+2OpiXkJYwMUybi9JkTmCfNeOLdpPjHI0lJ47iG2cmiiMNYWJ5hXKqmdxigQt5W/5WShjD4iYh6PcjzdD6IBD5UT8d3fTKWa4cvJs2bnILjElXOWo+s8o5NGAL359+QbCQfiWYha1P4SdSNhX98SXy0K6Dla0+Ny+miEVIf5N0jXvNlsJnnjjGgK+9gPGlU9mEbpF+4EqV2XgGp+Tg8ZGO489kK2S37gJTKMUhU+haMhAF0eoj5FSMN30LcXnHTjMjsps4hpeGeUu8gb0dm96+vvmAAibt9wnCULgMFKIEpP10IY5l4D8puLTi+smvf0MMvvrGRC3+aAq+jfemQEmJZgfhlhtr1O5DdUoTvcWazmNdNScwoJ68M/D/45pplBUglECub/Ib34HKNGmEMX5+tbDEHxe589A2Jwxvq1meycCnj3IoL/lXSprB7jWt3ZInURTJItaS5GSw9D4vLboe4OISAHZftdgGJea2rXJYPZk6N5L0EbLfftEh+zzOTb3Zr3MsIiCdxpWLshaDfiijRgVYsAd0rsx0LG+8+Icb33KvN3MK/ol8PjavM+T7F2qUIiKyaA5eZ9B5CAkm7YGvrE1TBYHeEpsgvaGnRyl3PcPYSCoOQ5ckZ67briJlf6OpZM+5P6u/MP/BWhQa8Ksox2SO4aZEtDzceWAHI4ccJSbD8h/jpoLZbSm2Z+CqNUmqr1atZnZgpgArKdcnxErljkCOfkp+k6nRkhg5RkbOCjCCATMGCSqGSIb3DQEHAaCCASQEggEgMIIBHDCCARgGCyqGSIb3DQEMCgECoIG0MIGxMBwGCiqGSIb3DQEMAQMwDgQIybmfLdxQ2ZUCAggABIGQQm1iiwDNnPOMO1rBboRmVPjVzV8OLeEVLNz2qhOhSR7nEBMSw289dZzdgbY/PZh2GrO0duJSHIzjBj7W9ShHVB6zdl0kZx1JRh5aJie3eEZ76l9zVOZadp2T0wCd7HD7c4rOYJFjuTwPVB4/GWzlA3r2HVGiyuQR8N8Z855M97/KrUCByR6HG1Nl8e1+EYyrMVIwIwYJKoZIhvcNAQkVMRYEFBZc8x6cgn9EB/NImL993PHArfe6MCsGCSqGSIb3DQEJFDEeHhwAZABlAHYAaQBjAGUALQBmAGkAeAB0AHUAcgBlMDEwITAJBgUrDgMCGgUABBQGw7bWopAP93FLjXc4LIiReHWd5AQISUwQzLqUrPECAggA"
}

View File

@@ -0,0 +1,114 @@
import Foundation
import Testing
@testable import ClientTLS
// C-iOS-1 · The responder truth table: 3 challenge types × identity present /
// absent. Exercised through the pure method-keyed core AND through a real
// `URLAuthenticationChallenge` (proving the method is extracted correctly),
// with no live socket.
private let responder = MutualTLSChallengeResponder()
private func makeIdentity() throws -> ClientIdentity {
try PKCS12Importer.importIdentity(
data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
)
}
// MARK: - ClientCertificate
@Test("ClientCertificate + identity → .useCredential with a credential")
func clientCertWithIdentityUsesCredential() throws {
// Arrange
let identity = try makeIdentity()
// Act
let resolution = responder.resolve(
authenticationMethod: NSURLAuthenticationMethodClientCertificate, identity: identity
)
// Assert
#expect(resolution.disposition == .useCredential)
#expect(resolution.credential != nil)
#expect(resolution.credential?.identity != nil)
}
@Test("ClientCertificate + no identity → .cancelAuthenticationChallenge, no credential")
func clientCertWithoutIdentityCancels() {
// Act
let resolution = responder.resolve(
authenticationMethod: NSURLAuthenticationMethodClientCertificate, identity: nil
)
// Assert
#expect(resolution.disposition == .cancelAuthenticationChallenge)
#expect(resolution.credential == nil)
}
// MARK: - ServerTrust
@Test("ServerTrust → .performDefaultHandling regardless of identity")
func serverTrustDefaultHandling() throws {
let identity = try makeIdentity()
for id in [identity, nil] as [ClientIdentity?] {
let resolution = responder.resolve(
authenticationMethod: NSURLAuthenticationMethodServerTrust, identity: id
)
#expect(resolution.disposition == .performDefaultHandling)
#expect(resolution.credential == nil)
}
}
// MARK: - Other method (e.g. Basic)
@Test("an unrelated method → .performDefaultHandling regardless of identity")
func otherMethodDefaultHandling() throws {
let identity = try makeIdentity()
for id in [identity, nil] as [ClientIdentity?] {
let resolution = responder.resolve(
authenticationMethod: NSURLAuthenticationMethodHTTPBasic, identity: id
)
#expect(resolution.disposition == .performDefaultHandling)
#expect(resolution.credential == nil)
}
}
// MARK: - Full URLAuthenticationChallenge extraction
@Test("resolve(challenge:) extracts the method from the protection space")
func resolveFromRealChallenge() throws {
// Arrange a real challenge for the ClientCertificate method.
let identity = try makeIdentity()
let challenge = makeChallenge(method: NSURLAuthenticationMethodClientCertificate)
// Act
let withIdentity = responder.resolve(challenge, identity: identity)
let withoutIdentity = responder.resolve(challenge, identity: nil)
// Assert
#expect(withIdentity.disposition == .useCredential)
#expect(withIdentity.credential != nil)
#expect(withoutIdentity.disposition == .cancelAuthenticationChallenge)
}
// MARK: - Helpers
/// Minimal sender so `URLAuthenticationChallenge` can be constructed in-process
/// (its designated init requires a non-optional sender). The responder never
/// calls back into the sender it only reads `protectionSpace`.
private final class NoopChallengeSender: NSObject, URLAuthenticationChallengeSender {
func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {}
func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {}
func cancel(_ challenge: URLAuthenticationChallenge) {}
}
private func makeChallenge(method: String) -> URLAuthenticationChallenge {
let space = URLProtectionSpace(
host: "t1.terminal.yaojia.wang", port: 443, protocol: "https",
realm: nil, authenticationMethod: method
)
return URLAuthenticationChallenge(
protectionSpace: space, proposedCredential: nil, previousFailureCount: 0,
failureResponse: nil, error: nil, sender: NoopChallengeSender()
)
}

View File

@@ -0,0 +1,69 @@
import Foundation
import Security
import Testing
@testable import ClientTLS
// C-iOS-1 · PKCS#12 import against a real OpenSSL-generated fixture `.p12`,
// including the wrong-passphrase and corrupt-file error mappings.
@Test("import with the correct passphrase yields an identity whose leaf CN matches")
func importSucceedsAndExposesLeaf() throws {
// Arrange
let data = ClientTLSFixtures.deviceP12Data
// Act
let identity = try PKCS12Importer.importIdentity(
data: data, passphrase: ClientTLSFixtures.passphrase
)
// Assert
let summary = try #require(identity.summary())
#expect(summary.subjectCommonName == ClientTLSFixtures.leafCommonName)
#expect(summary.issuerCommonName == ClientTLSFixtures.issuerCommonName)
// 825-day leaf minted at authoring time not yet expired.
#expect(summary.isExpired() == false)
}
@Test("import drops the leaf from the issuer chain (credential must not repeat it)")
func importIssuerChainExcludesLeaf() throws {
// Arrange / Act
let identity = try PKCS12Importer.importIdentity(
data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
)
// Assert fixture chain is [leaf, CA]; issuerCertificates keeps only the CA.
let leaf = try #require(identity.leafCertificate())
let leafData = SecCertificateCopyData(leaf) as Data
#expect(identity.issuerCertificates.count == 1)
for issuer in identity.issuerCertificates {
#expect((SecCertificateCopyData(issuer) as Data) != leafData)
}
}
@Test("wrong passphrase maps errSecAuthFailed → .wrongPassphrase")
func importWrongPassphrase() {
// Arrange / Act / Assert
#expect(throws: PKCS12ImportError.wrongPassphrase) {
_ = try PKCS12Importer.importIdentity(
data: ClientTLSFixtures.deviceP12Data, passphrase: "not-the-passphrase"
)
}
}
@Test("garbage bytes map errSecDecode → .corruptFile")
func importCorruptFile() {
// Arrange
let garbage = Data([0x00, 0x01, 0x02, 0x03, 0x99, 0xAB, 0xCD, 0xEF])
// Act / Assert
#expect(throws: PKCS12ImportError.corruptFile) {
_ = try PKCS12Importer.importIdentity(data: garbage, passphrase: "x")
}
}
@Test("empty data is treated as a corrupt file, not a crash")
func importEmptyData() {
#expect(throws: PKCS12ImportError.corruptFile) {
_ = try PKCS12Importer.importIdentity(data: Data(), passphrase: "x")
}
}

View File

@@ -1,7 +1,9 @@
// swift-tools-version: 6.0
// T-iOS-1 scaffold shell. SessionEngine / ReconnectMachine / PingScheduler /
// GateState / AwayDigest / URLSessionTermTransport land in W1W2 tasks.
// Dependency direction is strictly downward: SessionCore WireProtocol only.
// Dependency direction is strictly downward: SessionCore WireProtocol, plus
// ClientTLS (C-iOS-2) for the device client-cert mTLS responder the WS transport
// answers connection-level challenges with. Both are downward leaves.
import PackageDescription
let package = Package(
@@ -12,12 +14,16 @@ let package = Package(
],
dependencies: [
.package(path: "../WireProtocol"),
.package(path: "../ClientTLS"),
.package(path: "../TestSupport"),
],
targets: [
.target(
name: "SessionCore",
dependencies: [.product(name: "WireProtocol", package: "WireProtocol")]
dependencies: [
.product(name: "WireProtocol", package: "WireProtocol"),
.product(name: "ClientTLS", package: "ClientTLS"),
]
),
.testTarget(
name: "SessionCoreTests",

View File

@@ -1,3 +1,4 @@
import ClientTLS
import Darwin
import Foundation
import WireProtocol
@@ -76,16 +77,45 @@ protocol ConnectionPinger: Sendable {
public struct URLSessionTermTransport: TermTransport {
/// `URLSessionWebSocketTask.maximumMessageSize` applied to every task.
private let maxMessageBytes: Int
/// C-iOS-2 (MEDIUM no-relaunch fix) · Resolve the optional device client
/// identity LAZILY, once per `connect`, so a certificate imported mid-run
/// takes effect on the NEXT connection without an app relaunch (a snapshot
/// captured at composition would stay stale). When the resolved identity is
/// present, the connection answers a `ClientCertificate` challenge (mTLS to a
/// tunneled host) with its credential; when `nil`, the challenge is cancelled
/// cleanly. Local (non-mTLS) hosts never issue the challenge, so a `nil`
/// result is inert there.
private let identityProvider: @Sendable () -> ClientIdentity?
public init() {
self.init(maxMessageBytes: Tunables.maxWSMessageBytes)
/// Fixed-identity convenience (snapshot callers / tests): wraps a constant
/// provider, so behaviour is identical to capturing the identity directly.
public init(identity: ClientIdentity? = nil) {
self.init(identityProvider: { identity })
}
/// C-iOS-2 (MEDIUM no-relaunch fix) · per-connect identity resolution: the
/// provider is re-consulted on every `connect`, so a nilinstalled
/// transition is picked up without relaunch.
public init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
self.init(
maxMessageBytes: Tunables.maxWSMessageBytes, identityProvider: identityProvider
)
}
/// Internal TEST seam: a shrunken cap makes the oversizeEMSGSIZE path
/// deterministic without 16 MiB fixtures. Production code paths always go
/// through `init()` and the frozen `Tunables.maxWSMessageBytes`.
init(maxMessageBytes: Int) {
/// through `init()` / `init(identityProvider:)` and the frozen
/// `Tunables.maxWSMessageBytes`.
init(maxMessageBytes: Int, identity: ClientIdentity? = nil) {
self.init(maxMessageBytes: maxMessageBytes, identityProvider: { identity })
}
init(
maxMessageBytes: Int,
identityProvider: @escaping @Sendable () -> ClientIdentity?
) {
self.maxMessageBytes = maxMessageBytes
self.identityProvider = identityProvider
}
public func connect(to endpoint: HostEndpoint) async throws -> TransportConnection {
@@ -93,9 +123,13 @@ public struct URLSessionTermTransport: TermTransport {
}
/// Internal: concrete-typed connect (tests assert task configuration;
/// `connectPingable` builds on it).
/// `connectPingable` builds on it). The identity is resolved HERE, per
/// connect, from `identityProvider` (no-relaunch pickup).
func openConnection(to endpoint: HostEndpoint) async throws -> WSConnection {
try await WSConnection.open(endpoint: endpoint, maxMessageBytes: maxMessageBytes)
try await WSConnection.open(
endpoint: endpoint, maxMessageBytes: maxMessageBytes,
identity: identityProvider()
)
}
}
@@ -135,6 +169,13 @@ final class WSConnection: NSObject, @unchecked Sendable {
private let frames: AsyncThrowingStream<String, any Error>
private let framesContinuation: AsyncThrowingStream<String, any Error>.Continuation
/// C-iOS-2 mTLS. Set once in `configure` BEFORE `task.resume()`, so the
/// connection-level challenge (which can only arrive after resume) always
/// reads a stable value without locking same set-once discipline as
/// `task`/`session`.
private var identity: ClientIdentity?
private let challengeResponder = MutualTLSChallengeResponder()
private override init() {
(frames, framesContinuation) = AsyncThrowingStream<String, any Error>.makeStream()
super.init()
@@ -143,9 +184,13 @@ final class WSConnection: NSObject, @unchecked Sendable {
/// Connect: build session+task, resume, await the delegate-driven
/// handshake (didOpen / didCompleteWithError no receive-error guessing),
/// then start the re-arming receive loop.
static func open(endpoint: HostEndpoint, maxMessageBytes: Int) async throws -> WSConnection {
static func open(
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity? = nil
) async throws -> WSConnection {
let connection = WSConnection()
connection.configure(endpoint: endpoint, maxMessageBytes: maxMessageBytes)
connection.configure(
endpoint: endpoint, maxMessageBytes: maxMessageBytes, identity: identity
)
try await connection.performHandshake()
connection.startReceiveLoop()
return connection
@@ -163,7 +208,12 @@ final class WSConnection: NSObject, @unchecked Sendable {
// MARK: Setup
private func configure(endpoint: HostEndpoint, maxMessageBytes: Int) {
private func configure(
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity?
) {
// Set BEFORE building the task/resume: the mTLS challenge can only fire
// after `task.resume()`, so this write happens-before any read of it.
self.identity = identity
// Origin: SINGLE source of truth = endpoint.originHeader (plan §5.1).
var request = URLRequest(url: endpoint.wsURL)
request.setValue(endpoint.originHeader, forHTTPHeaderField: "Origin")
@@ -311,6 +361,22 @@ extension WSConnection: URLSessionWebSocketDelegate {
takeHandshakeContinuation()?.resume()
}
/// C-iOS-2 · Connection-level auth challenge (server trust + client cert)
/// arrives task-level for a WS task. Delegate the decision to the pure
/// `MutualTLSChallengeResponder`: present the device identity for an mTLS
/// tunnel host, default-handle the LE server trust, cancel cleanly if a
/// client cert is demanded but none is installed. `identity` is set-once
/// before `resume()` (see `configure`), so this read needs no lock.
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
let resolution = challengeResponder.resolve(challenge, identity: identity)
completionHandler(resolution.disposition, resolution.credential)
}
/// Server close frame processed CLEAN close: the stream FINISHES
/// (distinguishable from the transport-error THROW path).
func urlSession(

View File

@@ -243,6 +243,29 @@ struct URLSessionTermTransportTests {
await connection.close()
}
@Test("identity provider is resolved PER CONNECT (no-relaunch pickup, not captured once)")
func identityProviderResolvedPerConnect() async throws {
// A cert imported mid-run must take effect on the NEXT connection. The
// transport therefore re-consults its provider on every `connect` rather
// than capturing a snapshot proven here by a provider whose invocation
// count grows once per connect (nil identity: ScriptedWSServer is plain
// ws, so the connection succeeds regardless).
let (server, endpoint) = try await Self.startServer()
defer { server.stop() }
let calls = CallCounter()
let transport = URLSessionTermTransport(identityProvider: {
calls.increment()
return nil
})
let first = try await transport.connect(to: endpoint)
await first.close()
let second = try await transport.connect(to: endpoint)
await second.close()
#expect(calls.value == 2)
}
@Test("connect to a dead port throws (handshake failure path, no hang)")
func connectToDeadPortThrows() async throws {
let server = ScriptedWSServer()
@@ -254,5 +277,14 @@ struct URLSessionTermTransportTests {
_ = try await URLSessionTermTransport().connect(to: endpoint)
}
}
/// Thread-safe invocation counter for the `@Sendable` identity provider
/// (URLSession may consult it off the test's isolation domain).
private final class CallCounter: @unchecked Sendable {
private let lock = NSLock()
private var count = 0
func increment() { lock.withLock { count += 1 } }
var value: Int { lock.withLock { count } }
}
}
#endif

View File

@@ -31,6 +31,8 @@ packages:
path: Packages/HostRegistry
APIClient:
path: Packages/APIClient
ClientTLS:
path: Packages/ClientTLS # C-iOS · device client-cert (mTLS) leaf package
TestSupport:
path: Packages/TestSupport # test doubles — WebTermTests only, never the app target
# SwiftTerm is the ONLY third-party dependency, attached to the App target
@@ -50,6 +52,7 @@ targets:
- package: SessionCore
- package: HostRegistry
- package: APIClient
- package: ClientTLS
- package: SwiftTerm
settings:
base:
@@ -119,6 +122,7 @@ targets:
- package: SessionCore
- package: HostRegistry
- package: APIClient
- package: ClientTLS
- package: TestSupport
settings:
base:

View File

@@ -25,6 +25,13 @@
--tabbar-h: 40px;
--keybar-h: 46px;
/* iOS notch / home-indicator clearance. index.html sets viewport-fit=cover,
* so the layout spans the physical screen edges; without these the top bar
* hides under the status bar and the bottom key-bar covers terminal output.
* Falls back to 0px on platforms without safe areas. */
--safe-t: env(safe-area-inset-top, 0px);
--safe-b: env(safe-area-inset-bottom, 0px);
--ui-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
@@ -56,7 +63,9 @@ body {
#tabbar {
position: fixed;
inset: 0 0 auto 0;
height: var(--tabbar-h);
box-sizing: border-box;
height: calc(var(--tabbar-h) + var(--safe-t));
padding-top: var(--safe-t);
background: linear-gradient(180deg, #181a22, var(--surface-1));
border-bottom: 1px solid var(--border);
display: flex;
@@ -285,7 +294,7 @@ body {
/* ── Terminal area ───────────────────────────────────────────────── */
#term {
position: absolute;
inset: var(--tabbar-h) 0 var(--keybar-h) 0;
inset: calc(var(--tabbar-h) + var(--safe-t)) 0 calc(var(--keybar-h) + var(--safe-b)) 0;
background: var(--bg);
overflow: hidden;
}
@@ -301,13 +310,14 @@ body {
#keybar {
position: fixed;
inset: auto 0 0 0;
height: var(--keybar-h);
box-sizing: border-box;
height: calc(var(--keybar-h) + var(--safe-b));
background: var(--surface-1);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
gap: 5px;
padding: 0 6px;
padding: 0 6px var(--safe-b);
overflow-x: auto;
overflow-y: hidden;
z-index: 1000;
@@ -382,7 +392,7 @@ body {
#searchbox {
position: fixed;
top: calc(var(--tabbar-h) + 8px);
top: calc(var(--tabbar-h) + var(--safe-t) + 8px);
right: 10px;
z-index: 1100;
display: flex;
@@ -480,7 +490,7 @@ body {
/* Settings panel */
#settingspanel {
position: fixed;
top: calc(var(--tabbar-h) + 8px);
top: calc(var(--tabbar-h) + var(--safe-t) + 8px);
right: 10px;
z-index: 1100;
background: var(--surface-2);
@@ -679,7 +689,7 @@ body {
/* Approval banner (H3) */
#approvalbar {
position: fixed;
inset: auto 0 var(--keybar-h) 0;
inset: auto 0 calc(var(--keybar-h) + var(--safe-b)) 0;
z-index: 1050;
display: flex;
align-items: center;
@@ -1547,9 +1557,10 @@ body {
body.home-open #keybar {
display: none;
}
/* Home chooser hides the key-bar, so reclaim its reserved space at the bottom. */
/* Home chooser hides the key-bar, so reclaim its reserved space at the bottom
* (keeping the home-indicator clearance so launcher content stays reachable). */
body.home-open #term {
bottom: 0;
bottom: var(--safe-b);
}
/* Desktop: float the Sessions/Projects toggle top-right, on the SAME row as the

View File

@@ -9,8 +9,24 @@
},
"scripts": {
"start": "tsx src/main.ts",
"start:phase1": "tsx src/main-phase1.ts",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"control-plane": "file:../control-plane",
"relay-auth": "file:../relay-auth",
"relay-contracts": "file:../relay-contracts",
"relay-e2e": "file:../relay-e2e",
"term-relay": "file:../term-relay",
"relay-web": "file:../relay-web",
"agent": "file:../agent",
"ws": "^8.18.0",
"pg": "^8.12.0",
"ioredis": "^5.4.1"
},
"devDependencies": {
"tsx": "^4.19.0"
}
}

View File

@@ -0,0 +1,56 @@
/**
* RELAY-PHASE1 · staging admin-token minter (run from relay-run/ so relay-auth resolves).
*
* cd relay-run && npx tsx scripts/mint-manage-token.ts --aud <BASE_DOMAIN> [--sub <accountId>]
*
* Mints a §4.3 `manage` capability token signed by the P5 PRIVATE key, for the deny-by-default
* control-plane admin API (POST /accounts, /accounts/:id/pairing-codes). `aud` MUST equal the CP's
* BASE_DOMAIN (its `expectedAud`). The admin API verifies signature+aud+rights and derives accountId
* ONLY from the token (INV3) — it does NOT require a live DPoP proof — but the issuer still stamps a
* well-formed `cnf.jkt` (from a throwaway ephemeral key) because issueCapabilityToken mandates it.
* TTL is clamped to 60 s, so mint immediately before each admin call.
*
* Prints ONLY the raw token to stdout; the signing key material is never printed.
*/
import { readFileSync } from 'node:fs'
import { issueCapabilityToken } from 'relay-auth'
import { generateEd25519KeyPair, exportEd25519PublicRaw } from 'relay-auth/src/crypto/ed25519.js'
import { jwkThumbprint } from 'relay-auth/src/crypto/thumbprint.js'
function arg(name: string, fallback?: string): string | undefined {
const i = process.argv.indexOf(`--${name}`)
return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : fallback
}
async function main(): Promise<void> {
const keyPath = arg('key', '/etc/relay/capability/capability-sign.key.pem') as string
const aud = arg('aud')
const sub = arg('sub', 'bootstrap') as string
const host = arg('host', '_manage_') as string
const rights = (arg('rights', 'manage') as string).split(',').map((r) => r.trim())
if (aud === undefined || aud.length === 0) {
process.stderr.write('FATAL: --aud <BASE_DOMAIN> is required\n')
process.exit(2)
}
const pem = readFileSync(keyPath, 'utf8')
const der = Buffer.from(pem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, ''), 'base64')
const signingKey = await globalThis.crypto.subtle.importKey('pkcs8', der, { name: 'Ed25519' }, false, ['sign'])
const eph = await generateEd25519KeyPair()
const cnfJkt = await jwkThumbprint(await exportEd25519PublicRaw(eph.publicKey))
const now = Math.floor(Date.now() / 1000)
const token = await issueCapabilityToken(
// Runtime only reads principal.accountId; a minimal shape is intentional for this staging tool.
{ principal: { accountId: sub } as never, aud, host, rights: rights as never, ttlSeconds: 60, cnfJkt },
signingKey,
now,
)
process.stdout.write(token)
}
main().catch((e: unknown) => {
process.stderr.write(`mint failed: ${e instanceof Error ? e.message : String(e)}\n`)
process.exit(1)
})

View File

@@ -0,0 +1,303 @@
/**
* Phase 1 PRODUCTION entrypoint — `npm run start:phase1`. Composes the SHARED-STORE data plane
* (B1B4) behind a publicly-bound TLS listener, serves the relay-web bundle same-origin as the
* browser WSS (D1), and hosts a STAGING operator token-mint (`POST /auth/mint`, B5/auth-mint.ts).
*
* Unlike Phase-0 `main.ts` (in-RAM fakes + self-signed dev CA), everything here is real and
* env-configured — one world of truth over the SAME Postgres + Redis as the control-plane (INV7,
* restart-safe): the host registry that gates mTLS (INV14) and route resolution, the Redis
* revocation bus that tears live tunnels down (INV12), and the shared P5 verify key (INV9).
*
* browser ──WSS(:BIND_PORT)──▶ relay-node ──opaque splice(INV2)──▶ agent tunnel ◀──mTLS(:AGENT_BIND_PORT)── agent
* │ P5 onUpgrade: Origin/CSWSH + capability verify + DPoP │ registry-gated verifyAgentCert
* └ same-origin: static bundle (D1) + POST /auth/mint (B5) └ Redis relay:revocations → teardown
*
* All configuration is from ENV (no hardcoded hosts/ports/secrets). Phase-0 `main.ts` is UNTOUCHED.
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { loadVerifyKeyFromEnv, KeyConfigError } from 'relay-auth/src/config/keys.js'
import { createPgPool, createQuery } from 'control-plane/src/db/pool.js'
import { createPgStores } from 'control-plane/src/store/pg.js'
import { createRedisClient } from 'control-plane/src/boot/redis.js'
import type {
MtlsVerifier,
TlsServerFactory,
} from 'term-relay/data-plane/agent-listener.js'
import { createRelayEnforceDeps } from './wiring/stores-pg.js'
import { createMtlsVerifier, type AsyncMtlsVerifier } from './wiring/mtls-verifier.js'
import { createStoreRouteResolver } from './wiring/route-resolver.js'
import { startRevocationSubscriber, type RevocableNode } from './wiring/revocation-subscriber.js'
import { createAuthorizer } from './wiring/authorizer.js'
import { buildDataPlane, makeDataPlaneConfig } from './wiring/data-plane.js'
import { makeAgentTlsServerFactory } from './servers/agent-tls.js'
import { startBrowserServer } from './servers/browser-server.js'
import { createAuthMintRoute, loadSigningKeyFromEnv } from './servers/auth-mint.js'
const DEFAULT_BIND_HOST = '0.0.0.0'
const DEFAULT_BIND_PORT = 443
const HERE = dirname(fileURLToPath(import.meta.url)) // <repo>/relay-run/src
const DEFAULT_WEB_ROOT = join(HERE, '..', '..', 'relay-web', 'public')
// ── env helpers (fail-fast on misconfiguration) ─────────────────────────────────────────────────
function requireEnv(name: string): string {
const v = process.env[name]
if (v === undefined || v.length === 0) {
throw new KeyConfigError(`required env ${name} is not set`)
}
return v
}
function requirePort(name: string): number {
const raw = requireEnv(name)
const n = Number(raw)
if (!Number.isInteger(n) || n < 1 || n > 65535) {
throw new KeyConfigError(`env ${name} must be an integer port 165535 (got ${JSON.stringify(raw)})`)
}
return n
}
function intEnv(name: string, fallback: number): number {
const raw = process.env[name]
if (raw === undefined || raw.length === 0) return fallback
const n = Number(raw)
if (!Number.isInteger(n) || n < 1 || n > 65535) {
throw new KeyConfigError(`env ${name} must be an integer port 165535 (got ${JSON.stringify(raw)})`)
}
return n
}
/**
* F5/INV9 · render an error for logs WITHOUT leaking the raw object. A raw PG/Redis error can carry
* the connection DSN (with password) in its properties, so we log only `.message` (+ `.code` when
* present, e.g. 'ECONNREFUSED') and never the object itself.
*/
function errText(e: unknown): string {
if (e instanceof Error) {
const code = (e as { code?: unknown }).code
return code === undefined ? e.message : `${e.message} (code=${String(code)})`
}
return String(e)
}
// ── async mTLS → sync-slot bridge ───────────────────────────────────────────────────────────────
interface MtlsBridge {
/** Sync `MtlsVerifier` for the data plane; reads the pre-computed verdict for this connection. */
readonly sync: MtlsVerifier
/** Wrap the real TLS factory so each peer is registry-verified (async) BEFORE `attach` runs. */
wrap(base: TlsServerFactory): TlsServerFactory
}
/**
* term-relay's `MtlsVerifier.verifyPeer` is SYNC, but a registry-backed verifier (B2) is inherently
* async (Postgres lookup) — the ASYNC IMPEDANCE flagged in mtls-verifier.ts. We bridge it WITHOUT
* editing term-relay (outside our lane) by doing the async verify in the TLS `onPeer` hook and
* caching the verdict keyed by the peer's DER, which the sync `verifyPeer` (called synchronously by
* `attach`, immediately after `onPeer` fires) then reads. The set→onPeer→get sequence runs
* synchronously inside one `.then` callback, so a single-slot cache per DER is race-free. Fail-closed
* throughout: a rejected/failed verify caches `null`, so `attach` closes the peer with 4401 (INV14).
*/
function bridgeAsyncMtls(
asyncMtls: AsyncMtlsVerifier,
onError: (e: unknown) => void,
): MtlsBridge {
const pending = new Map<string, { hostId: string; accountId: string } | null>()
const keyOf = (der: Uint8Array): string => Buffer.from(der).toString('base64')
const sync: MtlsVerifier = {
verifyPeer(peerCert) {
const k = keyOf(peerCert)
const verdict = pending.get(k) ?? null
pending.delete(k) // one-shot: consumed by the attach() that triggered this onPeer
return verdict
},
}
const wrap = (base: TlsServerFactory): TlsServerFactory => (opts, onPeer) =>
base(opts, (ws, der) => {
asyncMtls
.verifyPeer(der)
.then((verdict) => {
pending.set(keyOf(der), verdict)
onPeer(ws, der) // sync attach() → sync.verifyPeer(der) reads + consumes the verdict
})
.catch((e: unknown) => {
onError(e)
pending.set(keyOf(der), null) // fail-closed → attach() closes 4401
onPeer(ws, der)
})
})
return { sync, wrap }
}
// ── boot ────────────────────────────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
const now = (): number => Math.floor(Date.now() / 1000)
// Config (fail-fast; secrets are read but never logged — INV9).
const bindHost = process.env.BIND_HOST || DEFAULT_BIND_HOST
const bindPort = intEnv('BIND_PORT', DEFAULT_BIND_PORT)
const agentBindPort = requirePort('AGENT_BIND_PORT')
const tlsCertPath = requireEnv('TLS_CERT_PATH')
const tlsKeyPath = requireEnv('TLS_KEY_PATH')
const agentServerCertPath = requireEnv('AGENT_SERVER_CERT_PATH')
const agentServerKeyPath = requireEnv('AGENT_SERVER_KEY_PATH')
const agentCaCertPath = requireEnv('AGENT_CA_CERT_PATH')
const agentCaChainPath = requireEnv('AGENT_CA_CHAIN_PATH')
const baseDomain = requireEnv('BASE_DOMAIN')
const relayNodeId = requireEnv('RELAY_NODE_ID')
const trustDomain = requireEnv('RELAY_TRUST_DOMAIN')
const allowedOrigins = requireEnv('ALLOWED_ORIGINS')
.split(',')
.map((o) => o.trim())
.filter((o) => o.length > 0)
if (allowedOrigins.length === 0) {
throw new KeyConfigError('ALLOWED_ORIGINS must contain at least one origin (CSWSH exact-match)')
}
const pgUrl = requireEnv('PG_URL')
const redisUrl = requireEnv('REDIS_URL')
const webRoot = process.env.WEB_ROOT || DEFAULT_WEB_ROOT
// Shared P5 verify key (RELAY_AUTH_VERIFY_PUBKEY) — configured process-wide, never logged (INV9).
await loadVerifyKeyFromEnv()
// Shared stores: SAME Postgres + Redis as the control-plane (INV7).
const pool = createPgPool(pgUrl)
const query = createQuery(pool)
const stores = createPgStores(query)
const redis = createRedisClient(redisUrl)
const redisSubscriber = createRedisClient(redisUrl) // dedicated subscriber-mode connection
const deps = createRelayEnforceDeps({ query, redis })
const resolver = createStoreRouteResolver({ hosts: stores.hosts })
const asyncMtls = createMtlsVerifier({
caChainPem: readFileSync(agentCaChainPath, 'utf8'),
hosts: deps.hosts,
now,
onError: (e) => console.error('[mtls-verify]', errText(e)),
})
const mtlsBridge = bridgeAsyncMtls(asyncMtls, (e) => console.error('[mtls-bridge]', errText(e)))
const authorizer = createAuthorizer({ deps, allowedOrigins, now })
const config = makeDataPlaneConfig({ baseDomain, bindHost, bindPort, agentBindPort, relayNodeId })
const agentTlsFactory = makeAgentTlsServerFactory({
serverCertPath: agentServerCertPath,
serverKeyPath: agentServerKeyPath,
bindHost,
bindPort: agentBindPort,
onListening: () => console.log(`[agent-mtls] listening wss://${bindHost}:${agentBindPort}`),
onError: (e) => console.error('[agent-mtls]', errText(e)),
})
const dp = buildDataPlane({
config,
authorizer,
resolver,
mtls: mtlsBridge.sync,
now,
// Agent-TLS trust store for validating the agent's CLIENT leaf (requestCert+rejectUnauthorized).
// MUST be the FULL chain (intermediate + self-signed root): Node/OpenSSL rejects a leaf whose only
// anchor is a non-self-signed intermediate (no PARTIAL_CHAIN flag). Intermediate-only ⇒ every agent
// is reset at the TLS layer before attach() runs.
caBundle: [readFileSync(agentCaChainPath)],
onError: (e) => console.error('[data-plane]', errText(e)),
tlsServerFactory: mtlsBridge.wrap(agentTlsFactory),
})
// STAGING operator token-mint (B5). Enabled only when BOTH the password gate and the signing key
// are configured; otherwise the endpoint stays off (fail-closed) and static-only mode serves.
const operatorPassword = process.env.OPERATOR_PASSWORD ?? ''
const signPrivRaw = process.env.CAPABILITY_SIGN_PRIVKEY ?? ''
let onRequest: ReturnType<typeof createAuthMintRoute> | undefined
let mintEnabled = false
if (operatorPassword.length > 0 && signPrivRaw.length > 0) {
const signingKey = await loadSigningKeyFromEnv(signPrivRaw)
// F1: the public :443 mint MUST be rate-limited (brute-force of OPERATOR_PASSWORD → full shell).
// Reuse the shared Redis token bucket keyed on a salted hash of the client IP (raw IP never stored).
const mintRateSalt = process.env.MINT_RATE_SALT || 'relay-run-mint-rate-salt'
onRequest = createAuthMintRoute({
signingKey,
hosts: stores.hosts,
operatorPassword,
now,
rateLimit: { buckets: deps.buckets, salt: mintRateSalt },
onError: (e) => console.error('[auth-mint]', errText(e)),
})
mintEnabled = true
} else {
console.warn(
'[auth-mint] STAGING mint disabled — set OPERATOR_PASSWORD and CAPABILITY_SIGN_PRIVKEY to enable POST /auth/mint',
)
}
const browserServer = startBrowserServer({
certPath: tlsCertPath,
keyPath: tlsKeyPath,
bindHost,
bindPort,
node: dp.node,
landingHtml: '<!doctype html><title>relay</title>', // unused when staticRoot is set
staticRoot: webRoot,
...(onRequest ? { onRequest } : {}),
onListening: () => console.log(`[browser-wss] listening https://${bindHost}:${bindPort}`),
onError: (e) => console.error('[browser-wss]', errText(e)),
})
// INV12: a Redis relay:revocations kill-signal tears matching live tunnel(s) down on this node.
const revocableNode: RevocableNode = {
activeTunnels: () =>
[...dp.listener.tunnels().values()].map((t) => ({ hostId: t.hostId, accountId: t.accountId })),
closeStream: (hostId) => dp.node.closeTunnel(hostId),
}
const revsub = startRevocationSubscriber({
redisSubscriber,
node: revocableNode,
// INV10: log counts + scope KIND only — never signal.reason / terminal payload.
onApplied: (signal, hostsAffected) =>
console.log(`[revocation] applied scope=${signal.scope.kind} hostsAffected=${hostsAffected}`),
onDropped: () => console.warn('[revocation] dropped malformed kill-signal'),
onError: (e) => console.error('[revocation]', errText(e)),
})
console.log('\n=== relay-run Phase 1 READY ===')
console.log(`Base domain : ${baseDomain} trustDomain: ${trustDomain} node: ${relayNodeId}`)
console.log(`Browser WSS : https://${bindHost}:${bindPort} (static root: ${webRoot})`)
console.log(`Agent mTLS : wss://${bindHost}:${agentBindPort}`)
console.log(`Allowed origins : ${allowedOrigins.join(', ')}`)
console.log(`Operator mint : ${mintEnabled ? 'ENABLED (STAGING /auth/mint)' : 'disabled'}`)
console.log('Ctrl-C to stop.\n')
let shuttingDown = false
const shutdown = async (): Promise<void> => {
if (shuttingDown) return
shuttingDown = true
console.log('\nshutting down…')
try {
revsub.close()
browserServer.close()
dp.listener.close()
await Promise.allSettled([redis.quit(), redisSubscriber.quit(), pool.end()])
} catch (e) {
console.error('[shutdown]', errText(e))
} finally {
process.exit(0)
}
}
process.on('SIGINT', () => void shutdown())
process.on('SIGTERM', () => void shutdown())
}
main().catch((e) => {
console.error('fatal:', errText(e))
process.exit(1)
})

View File

@@ -37,6 +37,11 @@ export function makeAgentTlsServerFactory(opts: AgentTlsOptions): TlsServerFacto
const der = peer && peer.raw ? new Uint8Array(peer.raw) : new Uint8Array()
onPeer(wsToWebSocketLike(ws), der)
})
wss.on('error', (e) => opts.onError?.(e))
// Surface TLS-layer client failures (e.g. a client leaf that doesn't chain to `ca`). Node
// otherwise SWALLOWS 'tlsClientError' — the peer just sees an abrupt reset with no server signal,
// which is exactly what masked the intermediate-only-CA bug.
server.on('tlsClientError', (e) => opts.onError?.(e))
server.on('error', (e) => opts.onError?.(e))
server.listen(opts.bindPort, opts.bindHost, () => opts.onListening?.())
return {

View File

@@ -0,0 +1,314 @@
/**
* B5 · STAGING-ONLY operator token-mint (`POST /auth/mint`).
*
* ┌─ STAGING NOTICE ────────────────────────────────────────────────────────────────────────────┐
* │ The ONLY gate here is a shared `OPERATOR_PASSWORD` (constant-time compared). This is a │
* │ deliberate Phase-1 staging shortcut so an operator can mint a browser capability token without │
* │ the full human-auth stack. Phase 2 REPLACES this password gate with WebAuthn (P5 T5T8). │
* └────────────────────────────────────────────────────────────────────────────────────────────┘
*
* Flow: the operator's browser generates its OWN DPoP keypair, computes the base64url SHA-256 JWK
* thumbprint `jkt`, and POSTs `{ password, jkt, subdomain }`. On a correct password we resolve the
* subdomain to its host row in the CP `hosts` store and mint a short-lived (<=60 s) §4.3 capability
* token BOUND to that `jkt` (`cnf.jkt`, RFC 7800 proof-of-possession) via the REAL P5 issue path.
*
* SECURITY:
* - INV3: `sub`(accountId)/`host`(hostId)/`aud`(subdomain) come SOLELY from the authenticated CP
* store row — the request body only NAMES a subdomain and the client's own DPoP thumbprint; it
* never supplies an accountId/hostId.
* - INV9: the signing private key and the minted token are NEVER logged. `onError` receives only
* the thrown error (callers must not log request bodies / tokens either).
* - Deny-by-default: unknown/revoked subdomain, bad password, malformed body → 4xx, no token.
* - Input is validated at the boundary (Zod) BEFORE any store/crypto work; body size is capped.
*/
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
import { issueCapabilityToken, type AuthenticatedPrincipal, type TokenBucketStore } from 'relay-auth'
import type { CapabilityRight } from 'relay-contracts'
import type { HostStore } from 'control-plane/src/store/ports.js'
/** Route this handler owns. */
const MINT_PATH = '/auth/mint' as const
/** Minted tokens are connect-scoped and short-lived (P5 clamps issue to [30, 60] s). */
const MINT_TTL_SEC = 60
/** Reject an oversized request body before buffering it (DoS guard). */
const MAX_BODY_BYTES = 4096
/** Least-privilege: an operator connecting to their terminal needs only `attach`. */
const MINT_RIGHTS: readonly CapabilityRight[] = ['attach']
/** A base64url SHA-256 JWK thumbprint is exactly 43 chars of [A-Za-z0-9_-]. */
const JKT_RE = /^[A-Za-z0-9_-]{43}$/
/**
* F1 · public-mint brute-force throttle (per remote IP). Strict & low: a full bucket allows a short
* burst, then admits ~1 attempt every 5 s — enough for an operator's retries, useless for guessing
* OPERATOR_PASSWORD. Tuned so a fresh IP gets `MINT_RATE_BURST` immediate tries.
*/
export const MINT_RATE_BURST = 5
export const MINT_RATE_REFILL_PER_SEC = 0.2
/** A single DNS label (tenant subdomain): lowercase alnum + hyphens, 163 chars, no leading/trailing '-'. */
const SUBDOMAIN_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
/** The single CP `hosts` capability this route needs (interface segregation). */
export type SubdomainHostLookup = Pick<HostStore, 'getBySubdomain'>
/**
* F1 · per-remote-address throttle for the public `/auth/mint`. Reuses the shared (Redis) token
* bucket. Keyed on a SALTED HASH of the client IP — the raw IP is never used as a key. Omit to
* disable the throttle (unit tests / trusted-network deployments); production ALWAYS supplies it.
*/
export interface MintRateLimit {
/** Token-bucket store (Redis-backed in prod; the SAME store the relay's pre-auth limiter uses). */
readonly buckets: TokenBucketStore
/** Salt for hashing the client IP into the bucket key — prevents storing/inferring the raw IP. */
readonly salt: string
/** Bucket capacity (max immediate burst). Default {@link MINT_RATE_BURST}. */
readonly burst?: number
/** Refill tokens/second. Default {@link MINT_RATE_REFILL_PER_SEC}. */
readonly refillPerSec?: number
}
export interface AuthMintDeps {
/** P5 capability signing PRIVATE key (Ed25519 CryptoKey with ['sign']). NEVER logged. */
readonly signingKey: CryptoKey
/** CP hosts store — the ONLY source of accountId/hostId (INV3). */
readonly hosts: SubdomainHostLookup
/** Shared staging operator password (constant-time compared). Phase 2 → WebAuthn. */
readonly operatorPassword: string
/** Epoch-SECONDS clock (token iat/exp). */
readonly now: () => number
/** F1 per-IP brute-force throttle. When set, exhaustion returns 429 BEFORE the password compare. */
readonly rateLimit?: MintRateLimit
/** Observability seam — receives thrown errors only (never bodies/tokens/keys). */
readonly onError?: (e: unknown) => void
}
interface MintRequest {
readonly password: string
readonly jkt: string
readonly subdomain: string
}
/** Validate the untrusted JSON body at the boundary. Returns the typed request or `null` to reject. */
function parseMintRequest(json: unknown): MintRequest | null {
if (typeof json !== 'object' || json === null) return null
const { password, jkt, subdomain } = json as Record<string, unknown>
if (typeof password !== 'string' || password.length === 0) return null
if (typeof jkt !== 'string' || !JKT_RE.test(jkt)) return null
if (typeof subdomain !== 'string' || !SUBDOMAIN_RE.test(subdomain)) return null
return { password, jkt, subdomain }
}
/** Length-independent constant-time string equality (compares fixed-size SHA-256 digests). */
function safeEqual(a: string, b: string): boolean {
const ha = createHash('sha256').update(a).digest()
const hb = createHash('sha256').update(b).digest()
return timingSafeEqual(ha, hb)
}
/** Salted HMAC-SHA256 of the client IP → an OPAQUE, non-reversible bucket key (never the raw IP). */
function mintBucketKey(salt: string, remoteAddr: string): string {
return 'mint:' + createHmac('sha256', salt).update(remoteAddr).digest('hex').slice(0, 32)
}
/**
* F1 · consume one token from the per-IP mint bucket. Returns true when the request may proceed,
* false when the IP is throttled. A thrown store error is NOT swallowed here — it propagates to
* handleMint's outer catch, which denies (500, no token) and reports via `onError` (deny-by-default).
*/
async function allowMintAttempt(rl: MintRateLimit, remoteAddr: string, now: number): Promise<boolean> {
const key = mintBucketKey(rl.salt, remoteAddr)
const burst = rl.burst ?? MINT_RATE_BURST
const refillPerSec = rl.refillPerSec ?? MINT_RATE_REFILL_PER_SEC
return rl.buckets.take(key, refillPerSec, burst, now)
}
function sendJson(res: ServerResponse, status: number, body: unknown): void {
const payload = JSON.stringify(body)
res.writeHead(status, {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-store',
})
res.end(payload)
}
/** Path portion of a URL (query/hash stripped) — the route matches on path only. */
function pathOf(url: string): string {
return url.split('?', 1)[0].split('#', 1)[0]
}
/** Buffer the request body up to `maxBytes`; reject (and destroy the stream) if it exceeds the cap. */
function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
return new Promise<string>((resolve, reject) => {
let size = 0
const chunks: Buffer[] = []
req.on('data', (chunk: Buffer) => {
size += chunk.length
if (size > maxBytes) {
req.destroy()
reject(new Error('request body too large'))
return
}
chunks.push(chunk)
})
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
req.on('error', (e) => reject(e))
})
}
function operatorPrincipal(accountId: string, authAt: number): AuthenticatedPrincipal {
// Minimal authenticated principal — issueCapabilityToken reads only `.accountId` (INV3).
return {
kind: 'human',
accountId,
principalId: `operator:${accountId}`,
amr: ['passkey'],
authAt,
stepUpAt: null,
}
}
async function handleMint(
req: IncomingMessage,
res: ServerResponse,
deps: AuthMintDeps,
): Promise<void> {
try {
// F1: per-IP throttle BEFORE any body buffering / password compare — blunts a brute-force of
// OPERATOR_PASSWORD on the public :443 mint. Keyed on the SALTED IP hash (raw IP never stored).
if (deps.rateLimit !== undefined) {
const remoteAddr = req.socket?.remoteAddress ?? ''
if (!(await allowMintAttempt(deps.rateLimit, remoteAddr, deps.now()))) {
sendJson(res, 429, { error: 'rate_limited' })
return
}
}
let raw: string
try {
raw = await readBody(req, MAX_BODY_BYTES)
} catch {
sendJson(res, 413, { error: 'payload_too_large' })
return
}
let json: unknown
try {
json = JSON.parse(raw)
} catch {
sendJson(res, 400, { error: 'invalid_json' })
return
}
const parsed = parseMintRequest(json)
if (parsed === null) {
sendJson(res, 400, { error: 'invalid_request' })
return
}
const { password, jkt, subdomain } = parsed
// STAGING gate. Constant-time to avoid a password-length/prefix timing oracle.
if (!safeEqual(password, deps.operatorPassword)) {
sendJson(res, 401, { error: 'unauthorized' })
return
}
const host = await deps.hosts.getBySubdomain(subdomain)
if (host === null) {
sendJson(res, 404, { error: 'unknown_subdomain' })
return
}
if (host.status === 'revoked') {
sendJson(res, 403, { error: 'host_revoked' })
return
}
const nowSec = deps.now()
// INV3: accountId/hostId/subdomain are the store row's, NEVER the request body's.
const token = await issueCapabilityToken(
{
principal: operatorPrincipal(host.accountId, nowSec),
aud: host.subdomain,
host: host.hostId,
rights: MINT_RIGHTS,
ttlSeconds: MINT_TTL_SEC,
cnfJkt: jkt,
},
deps.signingKey,
nowSec,
)
// INV9: the token is a bearer secret — return it, NEVER log it.
sendJson(res, 200, { token })
} catch (e: unknown) {
deps.onError?.(e)
if (!res.headersSent) sendJson(res, 500, { error: 'internal_error' })
}
}
/**
* Build the `POST /auth/mint` pre-router for `startBrowserServer`'s `onRequest` hook.
*
* Returns `true` when it CLAIMS the request (its path is `/auth/mint`) so the caller must not also
* write a response — the actual mint completes asynchronously. Returns `false` for any other path so
* the request falls through to the static/landing handler.
*/
export function createAuthMintRoute(
deps: AuthMintDeps,
): (req: IncomingMessage, res: ServerResponse) => boolean {
return (req, res) => {
if (pathOf(req.url ?? '/') !== MINT_PATH) return false // not our route → fall through to static
if (req.method !== 'POST') {
sendJson(res, 405, { error: 'method_not_allowed' })
return true
}
void handleMint(req, res, deps)
return true
}
}
// ── P5 capability signing-key loader ──────────────────────────────────────────────────────────────
const PEM_BODY_RE = /-----BEGIN [^-]+-----|-----END [^-]+-----/g
/** Coerce to an ArrayBuffer-backed view (WebCrypto's importKey wants `Uint8Array<ArrayBuffer>`). */
function toArrayBufferView(u: Uint8Array): Uint8Array<ArrayBuffer> {
const out = new Uint8Array(u.byteLength)
out.set(u)
return out
}
/** Decode standard-or-URL base64 (padding optional) to bytes. */
function base64AnyToBytes(s: string): Uint8Array {
const std = s.replace(/-/g, '+').replace(/_/g, '/')
return new Uint8Array(Buffer.from(std, 'base64'))
}
/** Strip PEM armor + whitespace and base64-decode the body to DER bytes. */
function pemBodyToDer(pem: string): Uint8Array {
const b64 = pem.replace(PEM_BODY_RE, '').replace(/\s+/g, '')
return base64AnyToBytes(b64)
}
/**
* Load the P5 capability SIGNING key (Ed25519 private) from the `CAPABILITY_SIGN_PRIVKEY` env value.
* Accepts either a PKCS#8 PEM (the `gen-capability-key.sh` output — contains `-----BEGIN`) or a
* base64/base64url encoding of the PKCS#8 DER. Imported non-extractable with only `['sign']` usage.
*
* INV9: the raw key material is never logged; this throws a generic error on a malformed value.
*/
export async function loadSigningKeyFromEnv(raw: string): Promise<CryptoKey> {
const trimmed = raw.trim()
if (trimmed.length === 0) throw new Error('CAPABILITY_SIGN_PRIVKEY is empty')
const der = trimmed.includes('BEGIN') ? pemBodyToDer(trimmed) : base64AnyToBytes(trimmed)
if (der.length === 0) throw new Error('CAPABILITY_SIGN_PRIVKEY did not decode to any key bytes')
try {
return await globalThis.crypto.subtle.importKey(
'pkcs8',
toArrayBufferView(der),
{ name: 'Ed25519' },
false,
['sign'],
)
} catch {
// Never surface the underlying material in the error.
throw new Error('CAPABILITY_SIGN_PRIVKEY is not a valid PKCS#8 Ed25519 private key')
}
}

View File

@@ -12,6 +12,8 @@ import { APP_SUBPROTOCOL } from 'relay-contracts'
import type { UpgradeRequest } from 'term-relay/data-plane/upgrade.js'
import type { RelayNode } from 'term-relay/data-plane/relay-node.js'
import { wsToWebSocketLike } from '../wiring/socket-pipe.js'
import { serveStatic } from './static-web.js'
import { extractDpopProofFromSubprotocols } from './dpop-subprotocol.js'
export interface BrowserServerOptions {
readonly certPath: string
@@ -20,6 +22,20 @@ export interface BrowserServerOptions {
readonly bindPort: number
readonly node: RelayNode
readonly landingHtml: string
/**
* When set, the HTTP handler serves the built relay-web bundle from this directory (D1), SAME
* ORIGIN as the WSS (so Origin/CSP stay aligned). When unset, `landingHtml` is served — the
* Phase-0 dev fallback. The WS upgrade is unaffected either way (it rides the `upgrade` event).
*/
readonly staticRoot?: string
/**
* Optional pre-router (B5): consulted BEFORE `staticRoot`/`landingHtml` on every non-upgrade HTTP
* request. Return `true` to claim the request (the hook owns the response — it may finish it
* asynchronously); return `false` to fall through to the static/landing behavior below. Default
* (undefined) preserves D1's behavior exactly. WS upgrades never reach this hook (they ride the
* `upgrade` event), so same-origin `POST /auth/mint` can coexist with the WSS.
*/
readonly onRequest?: (req: IncomingMessage, res: ServerResponse) => boolean
readonly onListening?: () => void
readonly onError?: (e: unknown) => void
}
@@ -35,12 +51,26 @@ function parseCookies(header: string | undefined): Record<string, string> {
return out
}
function buildUpgradeRequest(req: IncomingMessage): UpgradeRequest {
/**
* Build the P1 `UpgradeRequest` from the raw upgrade request.
*
* DPoP transport (B7): the `dpop` REQUEST HEADER is read first (a proxy or native client MAY set it),
* but a browser's native WebSocket API cannot set headers, so relay-web offers the proof as an extra
* `term.dpop.<b64u>` subprotocol entry. The header WINS when both are present; otherwise the proof
* falls back to the subprotocol (fail-closed → null when absent/malformed). htu/htm are NOT set here:
* the authorizer re-derives them from the request's own resolved authority (`expectedAud`), never from
* client-supplied claims — so both transports feed the SAME downstream DPoP binding.
*
* `activeSessionCount` is supplied by the caller (the live per-tenant connection count, F2).
*/
export function buildUpgradeRequest(req: IncomingMessage, activeSessionCount: number): UpgradeRequest {
const proto = req.headers['sec-websocket-protocol']
const subprotocols = (typeof proto === 'string' ? proto.split(',') : [])
.map((v) => v.trim())
.filter((v) => v.length > 0)
const dpopHeader = req.headers['dpop']
const headerProof = typeof dpopHeader === 'string' && dpopHeader.length > 0 ? dpopHeader : null
const proof = headerProof ?? extractDpopProofFromSubprotocols(subprotocols)
return {
host: req.headers.host ?? '',
origin: typeof req.headers.origin === 'string' ? req.headers.origin : undefined,
@@ -48,15 +78,32 @@ function buildUpgradeRequest(req: IncomingMessage): UpgradeRequest {
subprotocols,
cookies: parseCookies(req.headers.cookie),
remoteAddr: req.socket.remoteAddress ?? '',
dpop: { proof: typeof dpopHeader === 'string' ? dpopHeader : null, publicKeyThumbprint: null },
activeSessionCount: 0,
dpop: { proof, publicKeyThumbprint: null },
activeSessionCount,
}
}
export function startBrowserServer(opts: BrowserServerOptions): Server {
const server = createServer(
{ cert: readFileSync(opts.certPath), key: readFileSync(opts.keyPath) },
(_req: IncomingMessage, res: ServerResponse) => {
(req: IncomingMessage, res: ServerResponse) => {
// Pre-router (B5): a claimed request is fully owned by the hook (e.g. POST /auth/mint) and
// must NOT fall through to static/landing (which would double-write the response).
if (opts.onRequest !== undefined && opts.onRequest(req, res)) return
// Static-bundle mode (Phase 1): serve relay-web from `staticRoot`, SAME-ORIGIN as the WSS.
// Non-upgrade HTTP requests only — WS upgrades never reach this handler (see `upgrade` event).
if (opts.staticRoot !== undefined) {
const file = serveStatic(opts.staticRoot, req.url ?? '/')
if (file) {
res.writeHead(file.status, file.headers)
res.end(file.body)
} else {
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
res.end('Not Found')
}
return
}
// Phase-0 fallback: a single landing page.
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
res.end(opts.landingHtml)
},
@@ -66,9 +113,24 @@ export function startBrowserServer(opts: BrowserServerOptions): Server {
handleProtocols: (protocols: Set<string>) =>
protocols.has(APP_SUBPROTOCOL) ? APP_SUBPROTOCOL : false,
})
// F2: best-effort concurrent-session count per tenant. The relay-node keeps its browser↔agent
// splice index PRIVATE (no public per-host stream count), so we approximate the account's "active
// sessions" with the number of browser WS connections currently open to the SAME tenant origin
// (single-tenant staging ⇒ subdomain↔host/account is 1:1). We pass the count of the OTHER live
// connections (this one excluded) so P5's `checkConcurrentSessions` sees only prior sessions. This
// over-counts unauthorized/failing connects until they close, which fails SAFE (toward the cap).
const liveByTenant = new Map<string, number>()
wss.on('connection', (ws: WsWebSocket, req: IncomingMessage) => {
const tenantKey = req.headers.host ?? ''
const priorCount = liveByTenant.get(tenantKey) ?? 0
liveByTenant.set(tenantKey, priorCount + 1)
ws.once('close', () => {
const next = (liveByTenant.get(tenantKey) ?? 1) - 1
if (next <= 0) liveByTenant.delete(tenantKey)
else liveByTenant.set(tenantKey, next)
})
opts.node
.handleBrowserUpgrade(buildUpgradeRequest(req), wsToWebSocketLike(ws))
.handleBrowserUpgrade(buildUpgradeRequest(req, priorCount), wsToWebSocketLike(ws))
.catch((e) => opts.onError?.(e))
})
server.on('error', (e) => opts.onError?.(e))

View File

@@ -0,0 +1,36 @@
/**
* B7 · Decode the browser's DPoP proof carried as an EXTRA WS subprotocol entry.
*
* A browser's native WebSocket API cannot set request headers, so relay-web offers the §4.3 DPoP
* proof-of-possession JWS as an additional `Sec-WebSocket-Protocol` entry
* `term.dpop.<base64url(proofJws)>`
* — mirroring how the capability token rides `term.token.<b64u>` (relay-contracts subprotocol.ts).
* This is the relay-side inverse of relay-web `encodeDpopSubprotocol`; the wire format is decoded
* byte-for-byte with the SAME isomorphic base64url helper both sides share (relay-contracts), so
* issuance and verification can never drift.
*
* FAIL-CLOSED (INV15): an absent entry OR malformed base64url / non-UTF-8 payload → `null`, so the
* downstream DPoP gate denies through the audited path instead of ever seeing a half-decoded proof.
*/
import { decodeBase64UrlString } from 'relay-contracts'
/** Subprotocol entry prefix carrying the DPoP proof (mirrors relay-web `DPOP_SUBPROTOCOL_PREFIX`). */
export const DPOP_SUBPROTOCOL_PREFIX = 'term.dpop.' as const
/**
* Extract the DPoP proof JWS from a parsed subprotocol list: find the first `term.dpop.` entry,
* strip the prefix, and base64url-decode the remainder. Returns `null` when the entry is absent,
* empty, or does not decode (deny-by-default).
*/
export function extractDpopProofFromSubprotocols(values: readonly string[]): string | null {
const entry = values.find((v) => v.startsWith(DPOP_SUBPROTOCOL_PREFIX))
if (entry === undefined) return null
const encoded = entry.slice(DPOP_SUBPROTOCOL_PREFIX.length)
if (encoded.length === 0) return null
try {
const decoded = decodeBase64UrlString(encoded)
return decoded.length > 0 ? decoded : null
} catch {
return null // fail-closed: malformed base64url or invalid UTF-8
}
}

View File

@@ -0,0 +1,96 @@
/**
* Pure static-file resolver for the browser HTTPS server (D1). Serves the built relay-web bundle
* SAME-ORIGIN as the browser WSS so the page's Origin and CSP stay aligned with the WS endpoint.
*
* Deterministic given (root, urlPath): it resolves the request path under `root`, reads the file,
* and returns its bytes + Content-Type on a hit — or `null` when the path is malformed, escapes
* `root` (traversal), or names no readable regular file. The caller maps `null` to a 404.
*
* Security (STRICT traversal guard): the resolved absolute path MUST be `root` itself or a
* descendant of it. `../`, percent-encoded `..` (`%2e%2e`), and NUL bytes all reject to `null` —
* this function never reads a byte outside `root`.
*/
import { readFileSync, statSync } from 'node:fs'
import { resolve, sep, extname } from 'node:path'
export interface StaticFile {
readonly status: number
readonly headers: Record<string, string>
readonly body: Buffer
}
/** Served for a bare `/` request. */
const DEFAULT_DOC = 'index.html'
const OCTET_STREAM = 'application/octet-stream'
/** Extension → Content-Type. Covers what the relay-web bundle ships plus common static assets. */
const MIME_BY_EXT: Readonly<Record<string, string>> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.webmanifest': 'application/manifest+json',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.txt': 'text/plain; charset=utf-8',
}
function contentTypeFor(filePath: string): string {
return MIME_BY_EXT[extname(filePath).toLowerCase()] ?? OCTET_STREAM
}
/**
* Strip query/hash, percent-decode, and reject malformed / NUL-byte paths. Returns the path made
* relative to `root` (leading slashes stripped, `/` → `index.html`), or `null` to reject.
*/
function normalizeUrlPath(urlPath: string): string | null {
const noQuery = urlPath.split('?')[0].split('#')[0]
let decoded: string
try {
decoded = decodeURIComponent(noQuery)
} catch {
return null
}
if (decoded.includes('\0')) return null
const rel = decoded.replace(/^\/+/, '')
return rel.length === 0 ? DEFAULT_DOC : rel
}
/**
* Resolve `urlPath` to a file under `root`. Returns the file bytes + MIME on a hit, or `null` when
* the path is malformed, escapes `root`, or names no readable regular file.
*/
export function serveStatic(root: string, urlPath: string): StaticFile | null {
const rel = normalizeUrlPath(urlPath)
if (rel === null) return null
const rootAbs = resolve(root)
const resolved = resolve(rootAbs, rel)
// STRICT traversal guard: resolved must be the root itself or a descendant of it.
if (resolved !== rootAbs && !resolved.startsWith(rootAbs + sep)) return null
try {
if (!statSync(resolved).isFile()) return null
const body = readFileSync(resolved)
return {
status: 200,
headers: {
'content-type': contentTypeFor(resolved),
'content-length': String(body.length),
},
body,
}
} catch {
// ENOENT / EACCES / etc. — treat as "no servable file", caller 404s.
return null
}
}

View File

@@ -0,0 +1,93 @@
/**
* B2 · registry-backed MtlsVerifier (INV4/INV14) — replaces the Phase-0 stub in `relay-world.ts:149`
* that trusted ANY peer cert. Wraps relay-auth's `verifyAgentCert` against the SHARED host registry
* (the same `HostRegistryPort` B1 builds over Postgres) and the pinned agent-CA bundle, returning
* `{ hostId, accountId }` ONLY for an enrolled, unrevoked host whose leaf chains to our CA and is in
* its validity window. Every other outcome is `null` (fail-closed).
*
* INV3: `hostId`/`accountId` originate ONLY from the authenticated cert material (SPIFFE-ID) matched
* against the registry — never from a client-supplied field. INV14: the pubkey is bound to the
* registry AFTER CA-chain validation (a cert can chain to our CA yet still be denied if not enrolled).
*
* ── ASYNC IMPEDANCE (flagged for the orchestrator; adaptation per task B2) ──────────────────────
* The term-relay `MtlsVerifier.verifyPeer` (agent-listener.ts:16) is declared SYNC and its caller
* (agent-listener.ts:93) does NOT await it (`if (verified === null)`). A registry-backed verifier
* CANNOT be sync — `HostRegistryPort.getById` returns a Promise, so `verifyAgentCert` is async. This
* factory therefore returns an `AsyncMtlsVerifier` (Promise-returning `verifyPeer`). Wiring it into the
* data-plane requires `MtlsVerifier.verifyPeer` to become async END-TO-END: `agent-listener.ts` must
* `await` the result, and the `relay-world.ts:149` stub must be replaced. Those files are OUTSIDE B2's
* Owns. Until that migration lands, assigning this into the sync slot type-errors at the wiring site
* (`Promise<…>` is not assignable to `{…}|null`) — a useful compile-time tripwire, not a silent bug.
*/
import {
verifyAgentCert,
defaultParseX509,
type ParseCert,
type HostRegistryPort,
} from 'relay-auth'
const PEM_LINE_WIDTH = 64
/** Non-global (safe for `.test()`): asserts a bundle actually holds a PEM CERTIFICATE block. */
const PEM_CERT_RE = /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/
/**
* Async analog of term-relay's sync `MtlsVerifier` (agent-listener.ts). See ASYNC IMPEDANCE above:
* a registry lookup is inherently async, so `verifyPeer` returns a Promise.
*/
export interface AsyncMtlsVerifier {
verifyPeer(peerCertDer: Uint8Array): Promise<{ hostId: string; accountId: string } | null>
}
export interface MtlsVerifierDeps {
/** Pinned agent-CA bundle (PEM: intermediate(s) + root). NEVER the browser LE chain (INV14). */
readonly caChainPem: string
/** Shared host registry (same port B1 builds over Postgres). accountId/hostId only from here (INV3). */
readonly hosts: HostRegistryPort
/** Epoch-SECONDS provider (compared against the leaf's notBefore/notAfter). */
readonly now: () => number
/** Optional observability seam for a registry/parse fault; the verifier still fails closed. */
readonly onError?: (e: unknown) => void
/** Test seam: cert parser (defaults to production `defaultParseX509`). */
readonly parse?: ParseCert
}
/**
* Wrap raw DER bytes (`getPeerCertificate(true).raw`) into a PEM certificate block with 64-column
* base64, the shape node's `X509Certificate` (via `verifyAgentCert`) parses.
*/
export function derToPem(der: Uint8Array): string {
const b64 = Buffer.from(der).toString('base64')
const wrapped = b64.match(new RegExp(`.{1,${PEM_LINE_WIDTH}}`, 'g'))?.join('\n') ?? ''
return `-----BEGIN CERTIFICATE-----\n${wrapped}\n-----END CERTIFICATE-----\n`
}
export function createMtlsVerifier(deps: MtlsVerifierDeps): AsyncMtlsVerifier {
const { caChainPem, hosts, now } = deps
const onError = deps.onError ?? (() => {})
const parse = deps.parse ?? defaultParseX509
// Fail fast at construction on a misconfigured CA bundle (INV14: a real pinned CA is required; an
// empty/garbage bundle would otherwise silently deny every agent with an opaque `chain_invalid`).
if (typeof caChainPem !== 'string' || !PEM_CERT_RE.test(caChainPem)) {
throw new Error('createMtlsVerifier: caChainPem must contain at least one PEM CERTIFICATE block')
}
return {
async verifyPeer(peerCertDer) {
if (peerCertDer === undefined || peerCertDer === null || peerCertDer.length === 0) {
return null // no client cert presented → fail closed
}
try {
const leafPem = derToPem(peerCertDer)
const result = await verifyAgentCert(leafPem, caChainPem, now(), hosts, parse)
if (!result.ok || result.hostId === undefined || result.accountId === undefined) {
return null // expired / chain-invalid / not-enrolled / revoked / account-mismatch → fail closed
}
return { hostId: result.hostId, accountId: result.accountId }
} catch (e: unknown) {
onError(e) // registry/DB fault: surface it, never throw out of verifyPeer → fail closed
return null
}
},
}
}

View File

@@ -0,0 +1,121 @@
/**
* B4 · relay-run revocation subscriber (INV12 data-plane teardown). Bridges the frozen §4.2
* `relay:revocations` Redis pub/sub bus onto the running relay node so a revocation tears the live
* tunnel(s) down within the INV12 budget.
*
* Flow: an injected ioredis subscriber-mode client SUBSCRIBEs the one named channel; each message is
* validated as a `KillSignal` with the relay-contracts schema (malformed → dropped + counted, never
* a teardown and never a bus crash — a poisoned message can neither kill the bus nor fire a spurious
* kill); then for every live tunnel THIS node serves the pure relay-auth predicate
* `killsScope(signal, hostAccountId, hostId)` decides coverage and, on a match, the whole host is
* torn down (revoked ⇒ grace forced to 0 upstream in T11).
*
* Reuse, don't re-derive: scope math is `killsScope` (relay-auth) and validation is `KillSignalSchema`
* (relay-contracts) — this module only wires those pure primitives to a concrete ioredis client and
* the relay node. Security: scope selection is blast-radius-bounded (host ⊂ account ⊂ global); a host
* this node does not serve is a no-op (no cross-tenant reach). `signal.reason` is metadata only and is
* never written as terminal payload or log content (INV10). The owning `accountId` used for
* account-scope fan-out comes only from the authenticated tunnel (INV3).
*/
import { KillSignalSchema, RELAY_REVOCATIONS_CHANNEL, type KillSignal } from 'relay-contracts'
import { killsScope } from 'relay-auth'
/** One live tunnel this node currently serves: the host and the account that owns it. Both are
* authenticated material carried on the tunnel (INV3) — never derived from the untrusted signal. */
export interface ActiveTunnelRef {
readonly hostId: string
readonly accountId: string
}
/**
* What the subscriber needs from the running relay node: enumerate the tunnels it serves and tear a
* whole host's live streams/devices down (INV12). Kept narrow + injectable so the Phase-1 entry (B5)
* adapts the real data-plane {node, listener}: `activeTunnels` ← `listener.tunnels()` values,
* `closeStream(hostId)` ← `node.closeTunnel(hostId)` (the whole-host revocation lever).
*/
export interface RevocableNode {
/** Snapshot-friendly view of the tunnels currently attached to this node. */
activeTunnels(): Iterable<ActiveTunnelRef>
/** Immediately tear down every live stream/device on `hostId` (whole-host revocation, INV12). */
closeStream(hostId: string): void
}
/**
* Minimal structural view of an ioredis client in subscriber mode — only the members used here — so
* this module carries no hard dependency on ioredis and stays unit-testable with a fake. A real
* ioredis `Redis` instance satisfies this shape.
*/
export interface RedisSubscriber {
subscribe(channel: string): Promise<unknown>
unsubscribe(channel: string): Promise<unknown>
on(event: 'message', listener: (channel: string, message: string) => void): unknown
off(event: 'message', listener: (channel: string, message: string) => void): unknown
}
export interface RevocationSubscriberDeps {
readonly redisSubscriber: RedisSubscriber
readonly node: RevocableNode
/** Observability (metadata only, INV10): a valid signal was applied to `hostsAffected` hosts here. */
readonly onApplied?: (signal: KillSignal, hostsAffected: number) => void
/** Malformed-message counter — a dropped signal never fires a teardown (INV12 safety). */
readonly onDropped?: () => void
/** Boundary error routing for subscribe/unsubscribe failures — never silently swallowed. */
readonly onError?: (error: unknown) => void
}
export interface RevocationSubscription {
close(): void
}
function parseKillSignal(raw: string): KillSignal | null {
let json: unknown
try {
json = JSON.parse(raw)
} catch {
return null
}
const result = KillSignalSchema.safeParse(json)
return result.success ? result.data : null
}
export function startRevocationSubscriber(deps: RevocationSubscriberDeps): RevocationSubscription {
const { redisSubscriber, node, onApplied, onDropped, onError } = deps
const onMessage = (channel: string, message: string): void => {
if (channel !== RELAY_REVOCATIONS_CHANNEL) return // this client may also carry other channels
const signal = parseKillSignal(message)
if (signal === null) {
onDropped?.() // dropped + counted; bus stays alive, no teardown fired
return
}
// Snapshot first: closeStream tears down (mutates) the node's live-tunnel set as we iterate.
const tunnels = [...node.activeTunnels()]
let hostsAffected = 0
for (const { hostId, accountId } of tunnels) {
if (!killsScope(signal, accountId, hostId)) continue // blast-radius bounded; unrelated host = no-op
node.closeStream(hostId)
hostsAffected += 1
}
onApplied?.(signal, hostsAffected)
}
redisSubscriber.on('message', onMessage)
// Fire the SUBSCRIBE; route a rejected subscribe to onError (boundary I/O — never swallowed).
Promise.resolve(redisSubscriber.subscribe(RELAY_REVOCATIONS_CHANNEL)).catch((error: unknown) => {
onError?.(error)
})
let closed = false
return {
close(): void {
if (closed) return // idempotent teardown
closed = true
redisSubscriber.off('message', onMessage)
Promise.resolve(redisSubscriber.unsubscribe(RELAY_REVOCATIONS_CHANNEL)).catch(
(error: unknown) => {
onError?.(error)
},
)
},
}
}

View File

@@ -0,0 +1,40 @@
/**
* B3 · Store-backed RouteResolver — the PRODUCTION replacement for the one-entry in-RAM
* `memoryRouteResolver` (wiring/data-plane.ts:110). It implements term-relay's `RouteResolver`
* interface EXACTLY (`resolveSubdomain(subdomain) -> ResolvedHost | null`) by looking the tenant
* label up in the control-plane Postgres `hosts` store via `hosts.getBySubdomain(subdomain)`.
*
* Fail-closed (returns null, → 403 at the T8 upgrade edge) on:
* - unknown subdomain: `getBySubdomain` returns null.
* - revoked host: the row still exists (status is versioned, not deleted — INV8/INV12), so we
* must reject `status === 'revoked'` here; a revoked host must never resolve to a route.
*
* The resolver is only a HINT for candidate lookup (subdomain-router.ts): the returned `hostId`
* is what `authorizeUpgrade` feeds to P5 as `requestedHostId`, and P5 gates the signed token's
* `host` against it (INV1). Identity (`accountId`/`hostId`) here comes solely from the CP store —
* the ownership source of truth — never from the caller (INV3).
*/
import type { HostStore } from 'control-plane/src/store/ports.js'
import type { RouteResolver, ResolvedHost } from 'term-relay/data-plane/subdomain-router.js'
/**
* The single HostStore capability this resolver needs (interface segregation): a subdomain lookup.
* A full `createPgStores().hosts` (`HostStore`) satisfies it structurally.
*/
export type HostSubdomainLookup = Pick<HostStore, 'getBySubdomain'>
export interface StoreRouteResolverDeps {
readonly hosts: HostSubdomainLookup
}
/** Build a RouteResolver that resolves subdomain → host from the control-plane `hosts` store. */
export function createStoreRouteResolver({ hosts }: StoreRouteResolverDeps): RouteResolver {
return {
async resolveSubdomain(subdomain: string): Promise<ResolvedHost | null> {
const host = await hosts.getBySubdomain(subdomain)
if (host === null) return null // unknown subdomain — fail closed
if (host.status === 'revoked') return null // revoked host — fail closed (INV12)
return { hostId: host.hostId, accountId: host.accountId, subdomain: host.subdomain }
},
}
}

View File

@@ -82,8 +82,33 @@ function toUint8(data: unknown): Uint8Array | null {
return null
}
/** Adapt a live `ws` WebSocket to `WebSocketLike` (binary frames only). */
/**
* Adapt a live `ws` WebSocket to `WebSocketLike` (binary frames only).
*
* The `ws.on('message'|'close')` listeners are attached NOW (at construction), not when the consumer
* later calls `onMessage`/`onClose`. The composition root builds this synchronously on the socket's
* 'connection' event, but the mux consumer only wires its handler AFTER an async mTLS-registry lookup
* (the bridge). Any frame that arrives in that gap — notably the agent's FIRST heartbeat ping, sent
* immediately on open — would otherwise be dropped by `ws` (no listener yet), starving the heartbeat
* and flapping the tunnel every 15 s. We buffer early frames (and an early close) and flush on wire-up.
*/
export function wsToWebSocketLike(ws: WsWebSocket): WebSocketLike {
let onMessage: ((data: Uint8Array) => void) | null = null
let onClose: (() => void) | null = null
const backlog: Uint8Array[] = []
let closedEarly = false
ws.on('message', (data: unknown) => {
const bytes = toUint8(data)
if (bytes === null) return
if (onMessage === null) backlog.push(bytes)
else onMessage(bytes)
})
ws.on('close', () => {
if (onClose === null) closedEarly = true
else onClose()
})
return {
send(data: Uint8Array) {
ws.send(data, { binary: true })
@@ -92,13 +117,12 @@ export function wsToWebSocketLike(ws: WsWebSocket): WebSocketLike {
ws.close(code)
},
onMessage(handler) {
ws.on('message', (data: unknown) => {
const bytes = toUint8(data)
if (bytes !== null) handler(bytes)
})
onMessage = handler
while (backlog.length > 0) handler(backlog.shift()!)
},
onClose(handler) {
ws.on('close', () => handler())
onClose = handler
if (closedEarly) handler()
},
}
}

View File

@@ -0,0 +1,197 @@
/**
* B1 — shared-store `EnforceDeps` for relay-auth's `onUpgrade` pipeline, backed by the SAME
* Postgres + Redis as the control-plane (P3). This replaces the Phase-0 in-RAM fakes
* (`memory-stores.ts`) so the data-plane and the control-plane read one world of truth
* (restart-safe, INV7).
*
* Design:
* - hosts / sessions / audit -> Postgres via the CP repository adapter (`createPgStores(query)`).
* The CP `HostRecord` IS the relay-auth `HostRecord` (both re-export the frozen relay-contracts
* §4.2 shape), so `getById` is a direct pass-through; sessions/audit remap to the port shapes.
* - revocation / buckets -> Redis. `consumeOnce` is a single-use `SET NX` burn (Finding-4); the
* token bucket is an atomic Lua script (no read-modify-write race).
*
* SECURITY:
* - INV3: `accountId` is only ever read from authenticated material (the CP registry rows / the
* principal), never from client input. This adapter surfaces stored rows; it never fabricates an
* accountId from a request field.
* - Fail-closed: store/Redis errors are NOT swallowed — they reject and the enforcement pipeline
* denies the upgrade (deny-by-default).
* - INV9/INV10: audit rows carry metadata only (no keys, no terminal payload).
*/
import { createPgStores } from 'control-plane/src/store/pg.js'
import type { QueryFn } from 'control-plane/src/db/pool.js'
import type {
AuditEvent,
EnforceDeps,
HostRegistryPort,
RevocationStore,
SessionRegistryPort,
TokenBucketStore,
} from 'relay-auth'
import { NO_STEPUP_POLICY } from 'relay-auth/src/human/stepup/stepup.js'
// ── Redis surface (ioredis-compatible, injected loosely so tests can pass a mock) ────────────────
/**
* The minimal slice of an ioredis client this adapter uses. A real `ioredis` `Redis` instance
* structurally satisfies this (its methods are supersets), and a unit-test mock can implement it.
*/
export interface RedisLike {
exists(key: string): Promise<number>
set(key: string, value: string, mode?: 'NX'): Promise<string | null>
expireat(key: string, timestamp: number): Promise<number>
eval(script: string, numKeys: number, ...args: (string | number)[]): Promise<unknown>
}
export interface RelayEnforceDepsConfig {
readonly query: QueryFn
readonly redis: RedisLike
}
// ── Redis key namespaces (opaque to relay-auth) ──────────────────────────────────────────────────
const REVOKED_PREFIX = 'revoked:' as const
const USED_PREFIX = 'used:' as const
const BUCKET_PREFIX = 'bucket:' as const
/** Buffer added to the computed refill window before a fully-refilled bucket key may be reaped. */
const BUCKET_TTL_BUFFER_SEC = 1
/**
* Atomic token bucket (lazy refill). One round-trip, no read-modify-write race:
* KEYS[1]=bucket key · ARGV: refillPerSec, burst(capacity), now(epoch sec), ttl(sec).
* Returns 1 when a token was available (and consumed), 0 when throttled.
*/
const TOKEN_BUCKET_LUA = `
local key = KEYS[1]
local refill = tonumber(ARGV[1])
local burst = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local ttl = tonumber(ARGV[4])
local state = redis.call('HMGET', key, 't', 'ts')
local tokens = tonumber(state[1])
local ts = tonumber(state[2])
if tokens == nil then
tokens = burst
ts = now
end
local elapsed = now - ts
if elapsed > 0 then
tokens = math.min(burst, tokens + elapsed * refill)
end
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
end
redis.call('HSET', key, 't', tokens, 'ts', now)
redis.call('EXPIRE', key, ttl)
return allowed
`
/** Seconds a bucket needs to fully refill from empty, plus a small reap buffer. */
function bucketTtlSec(refillPerSec: number, burst: number): number {
const refillWindow = refillPerSec > 0 ? Math.ceil(burst / refillPerSec) : Math.ceil(burst)
return Math.max(1, refillWindow) + BUCKET_TTL_BUFFER_SEC
}
// ── Redis-backed ports ───────────────────────────────────────────────────────────────────────────
function redisRevocationStore(redis: RedisLike): RevocationStore {
return {
async isRevoked(jti) {
return (await redis.exists(REVOKED_PREFIX + jti)) > 0
},
async revokeJti(jti, exp) {
await redis.set(REVOKED_PREFIX + jti, '1')
await redis.expireat(REVOKED_PREFIX + jti, exp)
},
async consumeOnce(jti, exp) {
// First-use wins: SET NX returns 'OK' only when the key did not exist (Finding-4).
const set = await redis.set(USED_PREFIX + jti, '1', 'NX')
if (set !== 'OK') return false
await redis.expireat(USED_PREFIX + jti, exp)
return true
},
}
}
function redisTokenBucketStore(redis: RedisLike): TokenBucketStore {
return {
async take(key, refillPerSec, burst, now) {
const ttl = bucketTtlSec(refillPerSec, burst)
const allowed = await redis.eval(
TOKEN_BUCKET_LUA,
1,
BUCKET_PREFIX + key,
refillPerSec,
burst,
now,
ttl,
)
return Number(allowed) === 1
},
}
}
// ── Postgres-backed ports (over the CP repository adapter) ────────────────────────────────────────
/** Map a relay-auth `AuditEvent` onto the CP `audit_log` row shape (metadata only, INV10). */
function toAuditRow(e: AuditEvent): {
action: string
principalId: string
accountId: string
hostId: string | null
ts: string
meta: Record<string, string>
} {
const meta: Record<string, string> = {
outcome: e.outcome,
reason: e.reason,
remoteAddrHash: e.remoteAddrHash,
}
if (e.sessionId !== null) meta.sessionId = e.sessionId
if (e.jti !== null) meta.jti = e.jti
return {
action: e.action,
principalId: e.principalId,
accountId: e.accountId,
hostId: e.hostId,
ts: e.ts,
meta,
}
}
// ── Assembly ──────────────────────────────────────────────────────────────────────────────────────
/**
* Build the relay-auth `EnforceDeps` over the shared Postgres (`query`) + Redis (`redis`).
* `stepUpPolicyFor` returns `NO_STEPUP_POLICY` (staging single-operator; Phase 2 -> per-host WebAuthn).
*/
export function createRelayEnforceDeps({ query, redis }: RelayEnforceDepsConfig): EnforceDeps {
const stores = createPgStores(query)
const hosts: HostRegistryPort = {
// CP HostRecord === relay-auth HostRecord (both = relay-contracts §4.2) — direct pass-through.
getById: (hostId) => stores.hosts.get(hostId),
}
const sessions: SessionRegistryPort = {
getById: async (sessionId) => {
const rec = await stores.sessions.get(sessionId)
return rec === null ? null : { hostId: rec.hostId, accountId: rec.accountId }
},
}
return {
hosts,
sessions,
revocation: redisRevocationStore(redis),
buckets: redisTokenBucketStore(redis),
audit: {
append: (e) => stores.audit.append(toAuditRow(e)),
},
stepUpPolicyFor: () => NO_STEPUP_POLICY,
}
}

View File

@@ -0,0 +1,354 @@
/**
* B5 · unit tests for the STAGING operator token-mint (`POST /auth/mint`) and the P5 capability
* signing-key loader. Covers: the DPoP-bound short-lived token happy path (claims + PoP binding),
* the deny-by-default gates (bad password / unknown+revoked subdomain / malformed body / wrong
* method / oversized body), route claiming semantics, and PEM+base64 key loading round-trips.
*/
import { describe, it, expect, beforeAll } from 'vitest'
import { Readable } from 'node:stream'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { HostRecord } from 'control-plane/src/model/records.js'
import { verifyPaseto } from 'relay-auth/src/crypto/paseto.js'
import type { TokenBucketStore } from 'relay-auth'
import {
createAuthMintRoute,
loadSigningKeyFromEnv,
MINT_RATE_BURST,
type SubdomainHostLookup,
type MintRateLimit,
} from '../src/servers/auth-mint.js'
const subtle = globalThis.crypto.subtle
const NOW = 1_800_000_000
const PASSWORD = 'staging-operator-secret'
// A syntactically valid base64url SHA-256 JWK thumbprint (exactly 43 chars).
const JKT = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNO12'.padEnd(43, 'X').slice(0, 43)
interface Loaded {
readonly signingKey: CryptoKey
readonly publicKey: CryptoKey
}
let keys: Loaded
beforeAll(async () => {
const kp = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as {
publicKey: CryptoKey
privateKey: CryptoKey
}
keys = { signingKey: kp.privateKey, publicKey: kp.publicKey }
})
function mkHost(overrides: Partial<HostRecord> = {}): HostRecord {
return {
hostId: 'host-1',
accountId: 'acct-1',
subdomain: 'alice',
agentPubkey: new Uint8Array(32),
enrollFpr: 'fpr-host-1',
status: 'online',
lastSeen: '2026-01-01T00:00:00.000Z',
createdAt: '2026-01-01T00:00:00.000Z',
revokedAt: null,
...overrides,
}
}
function fakeHosts(rec: HostRecord | null): SubdomainHostLookup {
return { getBySubdomain: async () => rec }
}
interface CapturedRes {
statusCode: number
headers: Record<string, string>
body: string
headersSent: boolean
writeHead(status: number, headers: Record<string, string>): CapturedRes
end(chunk?: string): void
}
function fakeRes(): { res: ServerResponse; captured: CapturedRes; done: Promise<void> } {
let resolveDone!: () => void
const done = new Promise<void>((r) => (resolveDone = r))
const captured: CapturedRes = {
statusCode: 0,
headers: {},
body: '',
headersSent: false,
writeHead(status, headers) {
this.statusCode = status
this.headers = headers
this.headersSent = true
return this
},
end(chunk?: string) {
if (chunk !== undefined) this.body += chunk
resolveDone()
},
}
return { res: captured as unknown as ServerResponse, captured, done }
}
function fakeReq(method: string, url: string, body?: string, remoteAddr = '203.0.113.7'): IncomingMessage {
const chunks = body === undefined ? [] : [Buffer.from(body, 'utf8')]
const req = Readable.from(chunks) as unknown as IncomingMessage
;(req as { method?: string }).method = method
;(req as { url?: string }).url = url
;(req as { socket?: { remoteAddress: string } }).socket = { remoteAddress: remoteAddr }
return req
}
function mkRoute(hosts: SubdomainHostLookup): (req: IncomingMessage, res: ServerResponse) => boolean {
return createAuthMintRoute({
signingKey: keys.signingKey,
hosts,
operatorPassword: PASSWORD,
now: () => NOW,
onError: () => {},
})
}
async function post(
hosts: SubdomainHostLookup,
bodyObj: unknown,
): Promise<CapturedRes> {
const route = mkRoute(hosts)
const { res, captured, done } = fakeRes()
const claimed = route(fakeReq('POST', '/auth/mint', JSON.stringify(bodyObj)), res)
expect(claimed).toBe(true)
await done
return captured
}
describe('createAuthMintRoute — happy path', () => {
it('mints a short-lived capability token bound to the client jkt (INV3 identity from store)', async () => {
const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'alice' })
expect(captured.statusCode).toBe(200)
expect(captured.headers['cache-control']).toBe('no-store')
const parsedBody = JSON.parse(captured.body) as { token: string }
expect(typeof parsedBody.token).toBe('string')
const claims = (await verifyPaseto(parsedBody.token, keys.publicKey)) as {
sub: string
aud: string
host: string
rights: string[]
iat: number
exp: number
cnf: { jkt: string }
}
// Identity is the STORE row's, never the request body's (INV3).
expect(claims.sub).toBe('acct-1')
expect(claims.host).toBe('host-1')
expect(claims.aud).toBe('alice')
expect(claims.rights).toContain('attach')
// DPoP proof-of-possession binding to the client-provided thumbprint.
expect(claims.cnf.jkt).toBe(JKT)
// Short-lived (<= 60 s).
expect(claims.iat).toBe(NOW)
expect(claims.exp - claims.iat).toBeLessThanOrEqual(60)
expect(claims.exp - claims.iat).toBeGreaterThan(0)
// The token itself must never leak into logs — asserted by construction (no console here).
})
})
describe('createAuthMintRoute — deny by default', () => {
it('rejects a wrong password with 401 (no token)', async () => {
const captured = await post(fakeHosts(mkHost()), { password: 'wrong', jkt: JKT, subdomain: 'alice' })
expect(captured.statusCode).toBe(401)
expect(captured.body).not.toContain('token')
})
it('rejects an unknown subdomain with 404', async () => {
const captured = await post(fakeHosts(null), { password: PASSWORD, jkt: JKT, subdomain: 'ghost' })
expect(captured.statusCode).toBe(404)
})
it('rejects a revoked host with 403 (INV12: revoked never mints)', async () => {
const captured = await post(
fakeHosts(mkHost({ status: 'revoked', revokedAt: '2026-02-01T00:00:00.000Z' })),
{ password: PASSWORD, jkt: JKT, subdomain: 'alice' },
)
expect(captured.statusCode).toBe(403)
})
it('rejects a malformed jkt with 400', async () => {
const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: 'too-short', subdomain: 'alice' })
expect(captured.statusCode).toBe(400)
})
it('rejects a malformed subdomain with 400', async () => {
const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'Not_Valid!' })
expect(captured.statusCode).toBe(400)
})
it('rejects invalid JSON with 400', async () => {
const route = mkRoute(fakeHosts(mkHost()))
const { res, captured, done } = fakeRes()
const claimed = route(fakeReq('POST', '/auth/mint', '{not json'), res)
expect(claimed).toBe(true)
await done
expect(captured.statusCode).toBe(400)
})
it('rejects an oversized body with 413', async () => {
const big = 'x'.repeat(5000)
const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'alice', pad: big })
expect(captured.statusCode).toBe(413)
})
})
describe('createAuthMintRoute — routing semantics', () => {
it('claims /auth/mint but 405s a non-POST method', () => {
const route = mkRoute(fakeHosts(mkHost()))
const { res, captured } = fakeRes()
const claimed = route(fakeReq('GET', '/auth/mint', undefined), res)
expect(claimed).toBe(true)
expect(captured.statusCode).toBe(405)
})
it('does NOT claim a non-matching path (returns false, response untouched)', () => {
const route = mkRoute(fakeHosts(mkHost()))
const { res, captured } = fakeRes()
const claimed = route(fakeReq('POST', '/index.html', undefined), res)
expect(claimed).toBe(false)
expect(captured.statusCode).toBe(0)
})
it('claims /auth/mint even with a query string', () => {
const route = mkRoute(fakeHosts(null))
const { res } = fakeRes()
const claimed = route(fakeReq('GET', '/auth/mint?foo=1', undefined), res)
expect(claimed).toBe(true)
})
})
// ── F1 · per-IP mint throttle ─────────────────────────────────────────────────────────────────────
/** Lazy-refill token bucket mirroring the production Redis Lua — deterministic at a fixed `now`. */
function inMemoryBucket(): TokenBucketStore {
const state = new Map<string, { tokens: number; ts: number }>()
return {
async take(key, refillPerSec, burst, now) {
const s = state.get(key) ?? { tokens: burst, ts: now }
const elapsed = now - s.ts
let tokens = elapsed > 0 ? Math.min(burst, s.tokens + elapsed * refillPerSec) : s.tokens
const allowed = tokens >= 1
if (allowed) tokens -= 1
state.set(key, { tokens, ts: now })
return allowed
},
}
}
function mkRouteRl(
hosts: SubdomainHostLookup,
rateLimit: MintRateLimit,
): (req: IncomingMessage, res: ServerResponse) => boolean {
return createAuthMintRoute({
signingKey: keys.signingKey,
hosts,
operatorPassword: PASSWORD,
now: () => NOW,
rateLimit,
onError: () => {},
})
}
async function postRl(
route: (req: IncomingMessage, res: ServerResponse) => boolean,
bodyObj: unknown,
remoteAddr: string,
): Promise<CapturedRes> {
const { res, captured, done } = fakeRes()
route(fakeReq('POST', '/auth/mint', JSON.stringify(bodyObj), remoteAddr), res)
await done
return captured
}
describe('createAuthMintRoute — F1 per-IP throttle', () => {
const GOOD = { password: PASSWORD, jkt: JKT, subdomain: 'alice' }
it('admits the burst then returns 429 once the bucket is exhausted (same IP)', async () => {
const route = mkRouteRl(fakeHosts(mkHost()), { buckets: inMemoryBucket(), salt: 's' })
const ip = '198.51.100.4'
for (let i = 0; i < MINT_RATE_BURST; i++) {
const ok = await postRl(route, GOOD, ip)
expect(ok.statusCode).toBe(200)
}
const throttled = await postRl(route, GOOD, ip)
expect(throttled.statusCode).toBe(429)
expect(throttled.body).not.toContain('token')
})
it('throttles BEFORE the password compare (exhausted IP + WRONG password → 429, not 401)', async () => {
const route = mkRouteRl(fakeHosts(mkHost()), { buckets: inMemoryBucket(), salt: 's', burst: 1 })
const ip = '198.51.100.9'
expect((await postRl(route, GOOD, ip)).statusCode).toBe(200) // drains the single token
const wrong = await postRl(route, { ...GOOD, password: 'nope' }, ip)
expect(wrong.statusCode).toBe(429) // throttle wins over the 401 password check
})
it('keeps per-IP buckets independent (a throttled IP does not affect another)', async () => {
const route = mkRouteRl(fakeHosts(mkHost()), { buckets: inMemoryBucket(), salt: 's', burst: 1 })
expect((await postRl(route, GOOD, '10.0.0.1')).statusCode).toBe(200)
expect((await postRl(route, GOOD, '10.0.0.1')).statusCode).toBe(429) // IP-A exhausted
expect((await postRl(route, GOOD, '10.0.0.2')).statusCode).toBe(200) // IP-B unaffected
})
it('keys the bucket on a SALTED HASH, never the raw client IP', async () => {
const seenKeys: string[] = []
const spyBucket: TokenBucketStore = {
async take(key) {
seenKeys.push(key)
return true
},
}
const rawIp = '203.0.113.55'
const route = mkRouteRl(fakeHosts(mkHost()), { buckets: spyBucket, salt: 'pepper' })
await postRl(route, GOOD, rawIp)
expect(seenKeys).toHaveLength(1)
expect(seenKeys[0]).toMatch(/^mint:[0-9a-f]{32}$/)
expect(seenKeys[0]).not.toContain(rawIp)
})
})
describe('loadSigningKeyFromEnv', () => {
async function exportPkcs8Pem(): Promise<{ pem: string; b64: string; publicKey: CryptoKey }> {
const kp = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as {
publicKey: CryptoKey
privateKey: CryptoKey
}
const der = new Uint8Array(await subtle.exportKey('pkcs8', kp.privateKey))
const b64 = Buffer.from(der).toString('base64')
const pem = `-----BEGIN PRIVATE KEY-----\n${b64.match(/.{1,64}/g)!.join('\n')}\n-----END PRIVATE KEY-----\n`
return { pem, b64, publicKey: kp.publicKey }
}
async function canSign(priv: CryptoKey, pub: CryptoKey): Promise<boolean> {
const data = new Uint8Array([1, 2, 3, 4])
const sig = new Uint8Array(await subtle.sign({ name: 'Ed25519' }, priv, data))
return subtle.verify({ name: 'Ed25519' }, pub, sig, data)
}
it('loads a PKCS#8 PEM into a usable signing key', async () => {
const { pem, publicKey } = await exportPkcs8Pem()
const key = await loadSigningKeyFromEnv(pem)
expect(await canSign(key, publicKey)).toBe(true)
})
it('loads a bare base64 PKCS#8 DER into a usable signing key', async () => {
const { b64, publicKey } = await exportPkcs8Pem()
const key = await loadSigningKeyFromEnv(b64)
expect(await canSign(key, publicKey)).toBe(true)
})
it('throws on an empty value', async () => {
await expect(loadSigningKeyFromEnv(' ')).rejects.toThrow()
})
it('throws on a non-key value (never leaking material)', async () => {
await expect(loadSigningKeyFromEnv('not-a-real-key')).rejects.toThrow()
})
})

View File

@@ -0,0 +1,68 @@
/**
* B7 · unit tests for `buildUpgradeRequest` — the browser upgrade → P1 `UpgradeRequest` builder.
* Focus: the DPoP proof transport (FUNCTIONAL BLOCKER). A browser cannot set the `dpop` request
* header, so the proof rides the `term.dpop.<b64u>` subprotocol entry; the builder must read it there
* while keeping the header path working (header WINS if both). Also pins `activeSessionCount` plumbing.
*/
import { describe, it, expect } from 'vitest'
import type { IncomingMessage } from 'node:http'
import { encodeBase64UrlString, APP_SUBPROTOCOL } from 'relay-contracts'
import { buildUpgradeRequest } from '../src/servers/browser-server.js'
import { DPOP_SUBPROTOCOL_PREFIX } from '../src/servers/dpop-subprotocol.js'
const PROOF = 'eyJhbGciOiJFZERTQSJ9.eyJodHUiOiJodHRwczovL2FsaWNlL3dzIn0.c2ln'
function dpopEntry(proofJws: string): string {
return DPOP_SUBPROTOCOL_PREFIX + encodeBase64UrlString(proofJws)
}
function mkReq(headers: Record<string, string | undefined>): IncomingMessage {
return {
headers: { host: 'alice.example.com', ...headers },
url: '/ws',
socket: { remoteAddress: '1.2.3.4' },
} as unknown as IncomingMessage
}
describe('buildUpgradeRequest — DPoP transport', () => {
it('reads the proof from the term.dpop.<b64u> subprotocol when no header is set (the browser path)', () => {
const req = mkReq({ 'sec-websocket-protocol': `${APP_SUBPROTOCOL}, ${dpopEntry(PROOF)}` })
const upgrade = buildUpgradeRequest(req, 0)
expect(upgrade.dpop.proof).toBe(PROOF)
})
it('prefers the dpop HEADER over the subprotocol entry when both are present (header wins)', () => {
const req = mkReq({
dpop: 'header-proof-jws',
'sec-websocket-protocol': `${APP_SUBPROTOCOL}, ${dpopEntry(PROOF)}`,
})
expect(buildUpgradeRequest(req, 0).dpop.proof).toBe('header-proof-jws')
})
it('still honours the dpop header alone (header path unchanged)', () => {
const req = mkReq({ dpop: 'header-only', 'sec-websocket-protocol': APP_SUBPROTOCOL })
expect(buildUpgradeRequest(req, 0).dpop.proof).toBe('header-only')
})
it('yields proof=null when neither header nor subprotocol carries a proof', () => {
const req = mkReq({ 'sec-websocket-protocol': APP_SUBPROTOCOL })
expect(buildUpgradeRequest(req, 0).dpop.proof).toBeNull()
})
it('yields proof=null (fail-closed) for a malformed term.dpop. subprotocol entry', () => {
const req = mkReq({ 'sec-websocket-protocol': `${APP_SUBPROTOCOL}, ${DPOP_SUBPROTOCOL_PREFIX}not*b64u!` })
expect(buildUpgradeRequest(req, 0).dpop.proof).toBeNull()
})
it('parses the comma-separated subprotocol list and always carries the app subprotocol through', () => {
const req = mkReq({ 'sec-websocket-protocol': `${APP_SUBPROTOCOL}, ${dpopEntry(PROOF)}` })
expect(buildUpgradeRequest(req, 0).subprotocols).toContain(APP_SUBPROTOCOL)
})
})
describe('buildUpgradeRequest — activeSessionCount plumbing (F2)', () => {
it('carries the caller-supplied active-session count verbatim', () => {
const req = mkReq({ 'sec-websocket-protocol': APP_SUBPROTOCOL })
expect(buildUpgradeRequest(req, 3).activeSessionCount).toBe(3)
})
})

View File

@@ -0,0 +1,63 @@
/**
* B7 · unit tests for the relay-side DPoP subprotocol decoder (`extractDpopProofFromSubprotocols`).
* The browser (relay-web) offers the DPoP proof as an extra `term.dpop.<b64u(proofJws)>` entry on
* the WS upgrade because native WebSocket cannot set request headers. These tests pin the wire
* contract (valid decodes; absent → null; malformed → null, fail-closed) against the SAME isomorphic
* base64url helper relay-web encodes with (relay-contracts), so the two sides agree byte-for-byte.
*/
import { describe, it, expect } from 'vitest'
import { encodeBase64UrlString } from 'relay-contracts'
import {
extractDpopProofFromSubprotocols,
DPOP_SUBPROTOCOL_PREFIX,
} from '../src/servers/dpop-subprotocol.js'
import { APP_SUBPROTOCOL, TOKEN_SUBPROTOCOL_PREFIX } from 'relay-contracts'
/** Build the wire entry exactly as relay-web `encodeDpopSubprotocol` does. */
function dpopEntry(proofJws: string): string {
return DPOP_SUBPROTOCOL_PREFIX + encodeBase64UrlString(proofJws)
}
// A representative three-part DPoP proof JWS (header.payload.sig) — content is opaque to the decoder.
const PROOF = 'eyJhbGciOiJFZERTQSJ9.eyJodHUiOiJodHRwczovL2FsaWNlL3dzIn0.c2ln'
describe('extractDpopProofFromSubprotocols — valid', () => {
it('decodes a term.dpop.<b64u> entry back to the exact proof JWS', () => {
const subprotocols = [APP_SUBPROTOCOL, dpopEntry(PROOF)]
expect(extractDpopProofFromSubprotocols(subprotocols)).toBe(PROOF)
})
it('finds the DPoP entry regardless of position (after app + token entries)', () => {
const subprotocols = [
APP_SUBPROTOCOL,
TOKEN_SUBPROTOCOL_PREFIX + encodeBase64UrlString('v4.public.raw-token'),
dpopEntry(PROOF),
]
expect(extractDpopProofFromSubprotocols(subprotocols)).toBe(PROOF)
})
})
describe('extractDpopProofFromSubprotocols — absent → null', () => {
it('returns null when no term.dpop. entry is present', () => {
expect(extractDpopProofFromSubprotocols([APP_SUBPROTOCOL])).toBeNull()
})
it('returns null for an empty list', () => {
expect(extractDpopProofFromSubprotocols([])).toBeNull()
})
it('returns null when the prefix is present but the payload is empty', () => {
expect(extractDpopProofFromSubprotocols([DPOP_SUBPROTOCOL_PREFIX])).toBeNull()
})
})
describe('extractDpopProofFromSubprotocols — malformed → null (fail-closed)', () => {
it('returns null for a non-base64url payload (illegal characters)', () => {
expect(extractDpopProofFromSubprotocols([DPOP_SUBPROTOCOL_PREFIX + 'not*base64url!'])).toBeNull()
})
it('returns null for base64url bytes that are not valid UTF-8', () => {
// 0xFF 0xFE is not a valid UTF-8 sequence; base64url of those two bytes is "__4".
expect(extractDpopProofFromSubprotocols([DPOP_SUBPROTOCOL_PREFIX + '__4'])).toBeNull()
})
})

View File

@@ -0,0 +1,109 @@
/**
* Unit tests for the pure static-file resolver (D1). Covers index resolution, MIME mapping, query
* stripping, and the STRICT path-traversal guard (raw `..`, percent-encoded `%2e%2e`, and NUL byte
* all reject — even when the escaped target file genuinely exists on disk).
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { serveStatic } from '../src/servers/static-web.js'
const INDEX_HTML = '<!doctype html><title>idx</title>'
const PAIR_HTML = '<!doctype html><title>pair</title>'
const INDEX_JS = 'export const x = 1\n'
const XTERM_CSS = 'body{margin:0}'
const SECRET = 'TOP SECRET — outside root'
let base: string // temp parent dir (holds the secret sibling)
let root: string // the static root passed to serveStatic
beforeAll(() => {
base = mkdtempSync(join(tmpdir(), 'relay-static-'))
root = join(base, 'public')
mkdirSync(join(root, 'build'), { recursive: true })
writeFileSync(join(root, 'index.html'), INDEX_HTML)
writeFileSync(join(root, 'pair.html'), PAIR_HTML)
writeFileSync(join(root, 'build', 'index.js'), INDEX_JS)
writeFileSync(join(root, 'build', 'xterm.css'), XTERM_CSS)
// A real file OUTSIDE root — the traversal target the guard must never reach.
writeFileSync(join(base, 'secret.txt'), SECRET)
})
afterAll(() => {
rmSync(base, { recursive: true, force: true })
})
describe('serveStatic — index resolution', () => {
it('serves index.html at "/"', () => {
const res = serveStatic(root, '/')
expect(res).not.toBeNull()
expect(res?.status).toBe(200)
expect(res?.headers['content-type']).toBe('text/html; charset=utf-8')
expect(res?.body.toString('utf8')).toBe(INDEX_HTML)
expect(res?.headers['content-length']).toBe(String(Buffer.byteLength(INDEX_HTML)))
})
it('serves an explicit .html page', () => {
const res = serveStatic(root, '/pair.html')
expect(res?.status).toBe(200)
expect(res?.headers['content-type']).toBe('text/html; charset=utf-8')
expect(res?.body.toString('utf8')).toBe(PAIR_HTML)
})
it('strips the query string before resolving', () => {
const res = serveStatic(root, '/index.html?join=abc123')
expect(res?.status).toBe(200)
expect(res?.body.toString('utf8')).toBe(INDEX_HTML)
})
it('strips the hash fragment before resolving', () => {
const res = serveStatic(root, '/#section')
expect(res?.status).toBe(200)
expect(res?.body.toString('utf8')).toBe(INDEX_HTML)
})
})
describe('serveStatic — MIME types', () => {
it('maps /build/*.js to text/javascript', () => {
const res = serveStatic(root, '/build/index.js')
expect(res?.status).toBe(200)
expect(res?.headers['content-type']).toBe('text/javascript; charset=utf-8')
expect(res?.body.toString('utf8')).toBe(INDEX_JS)
})
it('maps .css to text/css', () => {
const res = serveStatic(root, '/build/xterm.css')
expect(res?.status).toBe(200)
expect(res?.headers['content-type']).toBe('text/css; charset=utf-8')
})
})
describe('serveStatic — traversal guard (returns null)', () => {
it('blocks a raw ".." escape even though the target exists', () => {
// Sanity: the target file really is readable outside root, so a null result proves the guard.
expect(serveStatic(root, '/../secret.txt')).toBeNull()
})
it('blocks a deep ".." escape', () => {
expect(serveStatic(root, '/build/../../secret.txt')).toBeNull()
})
it('blocks percent-encoded ".." (%2e%2e)', () => {
expect(serveStatic(root, '/%2e%2e/secret.txt')).toBeNull()
})
it('rejects a NUL byte in the path', () => {
expect(serveStatic(root, '/index.html%00.js')).toBeNull()
})
})
describe('serveStatic — misses (returns null)', () => {
it('returns null for a nonexistent file', () => {
expect(serveStatic(root, '/nope.html')).toBeNull()
})
it('returns null for a directory (no listing)', () => {
expect(serveStatic(root, '/build')).toBeNull()
})
})

View File

@@ -0,0 +1,158 @@
/**
* B2 tests — registry-backed MtlsVerifier (INV4/INV14, fail-closed).
*
* Two layers, mirroring relay-auth/test/mtls.test.ts:
* 1. REAL X.509 path: a self-signed root → leaf chain (relay-auth's committed mTLS fixtures) fed to
* `verifyPeer` as DER bytes, exercising the production `defaultParseX509` chain walk + DER→PEM
* conversion end-to-end against a fake host registry.
* 2. Deterministic seam: an injected `ParseCert` isolates the DER→PEM conversion + registry-gating +
* fail-closed mapping without depending on fixture contents.
*/
import { readFileSync } from 'node:fs'
import { X509Certificate } from 'node:crypto'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { describe, it, expect } from 'vitest'
import { defaultParseX509, spiffeIdFor, type ParseCert, type ParsedCert } from 'relay-auth'
import type { HostRegistryPort } from 'relay-auth'
import { createMtlsVerifier, derToPem } from '../../src/wiring/mtls-verifier.js'
import { fakeHostRegistry, makeHostRecord } from '../../src/wiring/memory-stores.js'
// Reuse the committed real mTLS fixtures (leaf chains to ca-chain; SPIFFE account/acct-A/host/host-1).
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), '../../../relay-auth/test/fixtures/mtls')
const readFixture = (name: string): string => readFileSync(join(FIXTURES, name), 'utf8')
const leafPem = readFixture('leaf.pem')
const caChainPem = readFixture('ca-chain.pem')
const foreignLeafPem = readFixture('foreign-leaf.pem')
const leafDer = new Uint8Array(new X509Certificate(leafPem).raw)
const foreignDer = new Uint8Array(new X509Certificate(foreignLeafPem).raw)
// A time strictly inside the leaf's validity window (fixtures are long-lived; do not hardcode).
const parsedLeaf = defaultParseX509(leafPem, caChainPem)
const IN_WINDOW = parsedLeaf.notBefore + 60
const enrolledHosts = (): HostRegistryPort =>
fakeHostRegistry([makeHostRecord('acct-A', 'host-1', 'sub-host-1')])
describe('derToPem (DER → PEM)', () => {
it('wraps DER as a 64-column PEM CERTIFICATE block that round-trips back to the same DER', () => {
const pem = derToPem(leafDer)
expect(pem.startsWith('-----BEGIN CERTIFICATE-----\n')).toBe(true)
expect(pem.trimEnd().endsWith('-----END CERTIFICATE-----')).toBe(true)
// No base64 body line exceeds the PEM 64-column width.
const body = pem.split('\n').slice(1, -2)
for (const line of body) expect(line.length).toBeLessThanOrEqual(64)
// Parsing the produced PEM yields byte-identical DER.
expect(new Uint8Array(new X509Certificate(pem).raw)).toEqual(leafDer)
})
})
describe('createMtlsVerifier — real X.509 path (INV14)', () => {
it('accepts an enrolled, in-date leaf chaining to the pinned CA → {hostId, accountId}', async () => {
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW })
expect(await v.verifyPeer(leafDer)).toEqual({ hostId: 'host-1', accountId: 'acct-A' })
})
it('refuses a leaf signed by a foreign CA not in the pinned bundle → null', async () => {
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW })
expect(await v.verifyPeer(foreignDer)).toBeNull()
})
it('refuses a valid leaf whose host is NOT in the registry (INV4) → null', async () => {
const v = createMtlsVerifier({ caChainPem, hosts: fakeHostRegistry([]), now: () => IN_WINDOW })
expect(await v.verifyPeer(leafDer)).toBeNull()
})
it('refuses when the registry account ≠ the cert SPIFFE account → null', async () => {
const hosts = fakeHostRegistry([makeHostRecord('acct-B', 'host-1', 'sub-host-1')])
const v = createMtlsVerifier({ caChainPem, hosts, now: () => IN_WINDOW })
expect(await v.verifyPeer(leafDer)).toBeNull()
})
it('refuses a revoked host (INV12/registry gate) → null', async () => {
const hosts = fakeHostRegistry([makeHostRecord('acct-A', 'host-1', 'sub-host-1', 'revoked')])
const v = createMtlsVerifier({ caChainPem, hosts, now: () => IN_WINDOW })
expect(await v.verifyPeer(leafDer)).toBeNull()
})
it('refuses an expired leaf → null', async () => {
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => parsedLeaf.notAfter + 100 })
expect(await v.verifyPeer(leafDer)).toBeNull()
})
it('refuses a not-yet-valid leaf → null', async () => {
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => parsedLeaf.notBefore - 100 })
expect(await v.verifyPeer(leafDer)).toBeNull()
})
})
describe('createMtlsVerifier — fail-closed on malformed / absent input', () => {
it('returns null for an empty DER (no client cert presented)', async () => {
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW })
expect(await v.verifyPeer(new Uint8Array(0))).toBeNull()
})
it('returns null for garbage DER bytes that are not a certificate', async () => {
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW })
expect(await v.verifyPeer(new Uint8Array([1, 2, 3, 4, 5]))).toBeNull()
})
})
describe('createMtlsVerifier — construction validation (INV14)', () => {
it('throws when the CA bundle is empty', () => {
expect(() => createMtlsVerifier({ caChainPem: '', hosts: enrolledHosts(), now: () => IN_WINDOW })).toThrow()
})
it('throws when the CA bundle has no PEM CERTIFICATE block', () => {
expect(() =>
createMtlsVerifier({ caChainPem: 'not a certificate', hosts: enrolledHosts(), now: () => IN_WINDOW }),
).toThrow()
})
})
// ── Deterministic seam: isolate DER→PEM + registry gating from fixture contents ──────────────────
const okParsed = (spiffeUri: string): ParsedCert => ({
spiffeUri,
notBefore: 0,
notAfter: 4_000_000_000,
chainValid: true,
})
describe('createMtlsVerifier — ParseCert seam', () => {
it('feeds verifyAgentCert the exact PEM produced by derToPem from the input DER', async () => {
let seenLeafPem: string | null = null
const capturingParse: ParseCert = (leaf) => {
seenLeafPem = leaf
return okParsed(spiffeIdFor('acct-A', 'host-1', 'example.com'))
}
const der = new Uint8Array([9, 8, 7, 6, 5, 4, 3, 2, 1])
const v = createMtlsVerifier({
caChainPem,
hosts: enrolledHosts(),
now: () => 1_000_000,
parse: capturingParse,
})
expect(await v.verifyPeer(der)).toEqual({ hostId: 'host-1', accountId: 'acct-A' })
expect(seenLeafPem).toBe(derToPem(der))
})
it('fails closed (null) and reports to onError when the registry lookup throws', async () => {
const errors: unknown[] = []
const throwingHosts: HostRegistryPort = {
getById: async () => {
throw new Error('registry unavailable')
},
}
const v = createMtlsVerifier({
caChainPem,
hosts: throwingHosts,
now: () => 1_000_000,
onError: (e) => errors.push(e),
parse: () => okParsed(spiffeIdFor('acct-A', 'host-1', 'example.com')),
})
expect(await v.verifyPeer(new Uint8Array([1, 2, 3]))).toBeNull()
expect(errors).toHaveLength(1)
})
})

Some files were not shown because too many files have changed in this diff Show More