feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)
Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.
Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay: native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent: host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e: browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth: Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web: browser login + Web Crypto E2E + client-side preview rendering
Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.
NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
This commit is contained in:
30
.github/workflows/relay-tripwire.yml
vendored
Normal file
30
.github/workflows/relay-tripwire.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# PERMANENT cross-tenant isolation tripwire (INV1) — see docs/PLAN_RELAY_AUTH_ISOLATION.md T13.
|
||||||
|
#
|
||||||
|
# This job MUST be a REQUIRED status check. A green build is impossible if cross-tenant isolation
|
||||||
|
# regresses (device A reaching host B). DO NOT delete, skip, or make this non-required. If it ever
|
||||||
|
# goes red, BLOCK the merge (CRITICAL per code-review.md).
|
||||||
|
name: relay-tripwire
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cross-tenant-tripwire:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: relay-auth
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
- name: Install relay-auth (+ local relay-contracts)
|
||||||
|
run: npm install
|
||||||
|
- name: Strict typecheck
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
- name: THE cross-tenant tripwire (A -> B must be 403)
|
||||||
|
run: npx vitest run tripwire/cross-tenant
|
||||||
|
- name: Full relay-auth suite
|
||||||
|
run: npx vitest run
|
||||||
1609
agent/package-lock.json
generated
Normal file
1609
agent/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
agent/package.json
Normal file
32
agent/package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "web-terminal-agent",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "P2 — Host Agent for the rendezvous-relay service. Ed25519 per-host identity, single-use pairing redemption (§4.5), outbound mTLS wss tunnel holding the §4.1 mux (codec via relay-contracts), loopback splice to the UNCHANGED web-terminal, heartbeat/backoff, cert auto-rotation, fast revocation, and the agent-side E2E endpoint (§4.4). Ciphertext-shuttle on the customer's own machine. See docs/PLAN_RELAY_AGENT.md.",
|
||||||
|
"bin": {
|
||||||
|
"web-terminal-agent": "dist/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"relay-contracts": "file:../relay-contracts",
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.9.3",
|
||||||
|
"@types/ws": "^8.5.12",
|
||||||
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
136
agent/src/certs/rotation.ts
Normal file
136
agent/src/certs/rotation.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Short-lived cert rotation — PLAN_RELAY_AGENT T13 (INV14). Renews the mTLS cert BEFORE expiry via
|
||||||
|
* P3's renewal endpoint using a fresh CSR over the SAME Ed25519 key (pubkey unchanged, only the
|
||||||
|
* cert rotates). Installs the new cert atomically (keystore writeFile is whole-file). A 403 from
|
||||||
|
* the renewal endpoint ⇒ the host was revoked ⇒ onRevoked (⇒ T14 teardown, INV12).
|
||||||
|
*
|
||||||
|
* NOTE (cross-plan / open Q#5): the renewal URL + its auth (mTLS with the current cert vs a short
|
||||||
|
* renewal token) is P3-owned. This derives `${enrollUrl}` → `.../renew` and injects fetch; when P3
|
||||||
|
* freezes the route, adjust `renewalUrlFor`. Single integration point.
|
||||||
|
*/
|
||||||
|
import { X509Certificate } from 'node:crypto'
|
||||||
|
import type { AgentConfig } from '../config/agentConfig.js'
|
||||||
|
import type { AgentIdentity } from '../keys/identity.js'
|
||||||
|
import type { Keystore } from '../keys/keystore.js'
|
||||||
|
import type { TimerLike } from '../transport/seams.js'
|
||||||
|
import { buildCsr } from '../enroll/csr.js'
|
||||||
|
|
||||||
|
export const DEFAULT_RENEW_BEFORE_MS = 5 * 60_000 // renew 5 min before expiry
|
||||||
|
|
||||||
|
export interface CertRotator {
|
||||||
|
start(): void
|
||||||
|
stop(): void
|
||||||
|
onRotated(cb: () => void): void
|
||||||
|
onRevoked(cb: () => void): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RenewOutcome = 'rotated' | 'revoked'
|
||||||
|
|
||||||
|
/** Derive P3's renewal route from the enroll URL (integration point, open Q#5). */
|
||||||
|
export function renewalUrlFor(cfg: AgentConfig): string {
|
||||||
|
return cfg.enrollUrl.replace(/\/enroll$/, '/renew')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ms until (validTo − renewBeforeMs), clamped to ≥ 0. */
|
||||||
|
export function computeRenewDelayMs(
|
||||||
|
certPem: string,
|
||||||
|
renewBeforeMs: number,
|
||||||
|
now: Date,
|
||||||
|
parse: (pem: string) => Date = (p) => new Date(new X509Certificate(p).validTo),
|
||||||
|
): number {
|
||||||
|
const validTo = parse(certPem).getTime()
|
||||||
|
return Math.max(0, validTo - renewBeforeMs - now.getTime())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform one renewal round-trip. Returns 'rotated' (new cert stored) or 'revoked' (403). Any
|
||||||
|
* other HTTP/network failure throws (caller retries with backoff; the tunnel stays up until the
|
||||||
|
* cert actually expires).
|
||||||
|
*/
|
||||||
|
export async function renewCert(
|
||||||
|
cfg: AgentConfig,
|
||||||
|
id: AgentIdentity,
|
||||||
|
ks: Keystore,
|
||||||
|
fetchImpl: typeof fetch,
|
||||||
|
): Promise<RenewOutcome> {
|
||||||
|
const csr = buildCsr(id, cfg.subdomain ?? 'web-terminal-agent')
|
||||||
|
const res = await fetchImpl(renewalUrlFor(cfg), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ csr }),
|
||||||
|
})
|
||||||
|
if (res.status === 403) return 'revoked'
|
||||||
|
if (!res.ok) throw new Error(`cert renewal failed: HTTP ${res.status}`)
|
||||||
|
const json = (await res.json()) as { cert?: string; caChain?: string }
|
||||||
|
if (typeof json.cert !== 'string' || typeof json.caChain !== 'string') {
|
||||||
|
throw new Error('cert renewal response missing cert/caChain')
|
||||||
|
}
|
||||||
|
ks.saveCert(json.cert, json.caChain) // atomic whole-file install
|
||||||
|
return 'rotated'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCertRotator(
|
||||||
|
cfg: AgentConfig,
|
||||||
|
id: AgentIdentity,
|
||||||
|
ks: Keystore,
|
||||||
|
opts: {
|
||||||
|
renewBeforeMs?: number
|
||||||
|
timer?: TimerLike
|
||||||
|
fetchImpl?: typeof fetch
|
||||||
|
now?: () => Date
|
||||||
|
parseCert?: (pem: string) => Date
|
||||||
|
} = {},
|
||||||
|
): CertRotator {
|
||||||
|
const renewBeforeMs = opts.renewBeforeMs ?? DEFAULT_RENEW_BEFORE_MS
|
||||||
|
const parseCert = opts.parseCert ?? ((p) => new Date(new X509Certificate(p).validTo))
|
||||||
|
const timer = opts.timer ?? {
|
||||||
|
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 doFetch = opts.fetchImpl ?? fetch
|
||||||
|
const now = opts.now ?? (() => new Date())
|
||||||
|
let handle: unknown = null
|
||||||
|
let rotatedCb: (() => void) | null = null
|
||||||
|
let revokedCb: (() => void) | null = null
|
||||||
|
|
||||||
|
function schedule(): void {
|
||||||
|
const certs = ks.loadCert()
|
||||||
|
if (certs === null) return
|
||||||
|
const delay = computeRenewDelayMs(certs.certPem, renewBeforeMs, now(), parseCert)
|
||||||
|
handle = timer.setTimeout(runRenewal, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
function runRenewal(): void {
|
||||||
|
void renewCert(cfg, id, ks, doFetch)
|
||||||
|
.then((outcome) => {
|
||||||
|
if (outcome === 'revoked') {
|
||||||
|
revokedCb?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rotatedCb?.()
|
||||||
|
schedule()
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// network error: retry after renewBeforeMs; the tunnel stays up meanwhile.
|
||||||
|
handle = timer.setTimeout(runRenewal, renewBeforeMs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start(): void {
|
||||||
|
schedule()
|
||||||
|
},
|
||||||
|
stop(): void {
|
||||||
|
if (handle !== null) timer.clearTimeout(handle)
|
||||||
|
handle = null
|
||||||
|
},
|
||||||
|
onRotated(cb): void {
|
||||||
|
rotatedCb = cb
|
||||||
|
},
|
||||||
|
onRevoked(cb): void {
|
||||||
|
revokedCb = cb
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
97
agent/src/cli.ts
Normal file
97
agent/src/cli.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* CLI entrypoint — PLAN_RELAY_AGENT T5. `pair | run | status | install | uninstall`.
|
||||||
|
* All side effects (network/FS/tunnel) are injected via `CliDeps` so tests avoid real IO.
|
||||||
|
* `status` prints host_id/subdomain/online ONLY — never key/cert material (INV9).
|
||||||
|
*/
|
||||||
|
import type { AgentConfig } from './config/agentConfig.js'
|
||||||
|
import type { AgentIdentity } from './keys/identity.js'
|
||||||
|
import type { Keystore } from './keys/keystore.js'
|
||||||
|
import type { EnrollResult } from 'relay-contracts'
|
||||||
|
|
||||||
|
export type CliCommand = 'pair' | 'run' | 'status' | 'install' | 'uninstall'
|
||||||
|
const COMMANDS: readonly CliCommand[] = ['pair', 'run', 'status', 'install', 'uninstall']
|
||||||
|
|
||||||
|
export interface CliArgs {
|
||||||
|
readonly command: CliCommand
|
||||||
|
readonly code?: string
|
||||||
|
readonly flags: Readonly<Record<string, string | boolean>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CliUsageError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'CliUsageError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CliDeps {
|
||||||
|
loadConfig(): AgentConfig
|
||||||
|
openKeystore(stateDir: string): Keystore
|
||||||
|
generateIdentity(): AgentIdentity
|
||||||
|
redeem(cfg: AgentConfig, code: string, id: AgentIdentity, ks: Keystore): Promise<EnrollResult>
|
||||||
|
runTunnel(cfg: AgentConfig, ks: Keystore): Promise<number>
|
||||||
|
installService(cfg: AgentConfig): Promise<void>
|
||||||
|
uninstallService(): Promise<void>
|
||||||
|
print(line: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse argv (already sliced past node/script) into a typed CliArgs. */
|
||||||
|
export function parseArgs(argv: readonly string[]): CliArgs {
|
||||||
|
const [command, ...rest] = argv
|
||||||
|
if (command === undefined || !COMMANDS.includes(command as CliCommand)) {
|
||||||
|
throw new CliUsageError(`unknown command '${command ?? ''}' (expected ${COMMANDS.join(' | ')})`)
|
||||||
|
}
|
||||||
|
const flags: Record<string, string | boolean> = {}
|
||||||
|
const positionals: string[] = []
|
||||||
|
for (const token of rest) {
|
||||||
|
if (token.startsWith('--')) {
|
||||||
|
const [k, v] = token.slice(2).split('=')
|
||||||
|
flags[k!] = v ?? true
|
||||||
|
} else {
|
||||||
|
positionals.push(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const args: CliArgs = { command: command as CliCommand, flags }
|
||||||
|
if (command === 'pair') {
|
||||||
|
const code = positionals[0]
|
||||||
|
if (code === undefined) throw new CliUsageError('usage: web-terminal-agent pair <CODE>')
|
||||||
|
return { ...args, code }
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispatch a parsed CliArgs; returns a process exit code (0 = success). */
|
||||||
|
export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
|
||||||
|
const cfg = deps.loadConfig()
|
||||||
|
const ks = deps.openKeystore(cfg.stateDir)
|
||||||
|
switch (args.command) {
|
||||||
|
case 'pair': {
|
||||||
|
const id = ks.loadIdentity() ?? deps.generateIdentity()
|
||||||
|
ks.saveIdentity(id)
|
||||||
|
const enroll = await deps.redeem(cfg, args.code!, id, ks)
|
||||||
|
deps.print(`paired: host ${enroll.hostId} subdomain ${enroll.subdomain}`)
|
||||||
|
if (args.flags['install']) await deps.installService(cfg)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
case 'run': {
|
||||||
|
if (ks.loadIdentity() === null || ks.loadCert() === null) {
|
||||||
|
throw new CliUsageError('not enrolled — run `web-terminal-agent pair <CODE>` first')
|
||||||
|
}
|
||||||
|
return deps.runTunnel(cfg, ks)
|
||||||
|
}
|
||||||
|
case 'status': {
|
||||||
|
const enrolled = ks.loadIdentity() !== null && ks.loadCert() !== null
|
||||||
|
// INV9: print only non-secret identifiers.
|
||||||
|
deps.print(`enrolled: ${enrolled}`)
|
||||||
|
deps.print(`host_id: ${cfg.hostId ?? '(none)'}`)
|
||||||
|
deps.print(`subdomain: ${cfg.subdomain ?? '(none)'}`)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
case 'install':
|
||||||
|
await deps.installService(cfg)
|
||||||
|
return 0
|
||||||
|
case 'uninstall':
|
||||||
|
await deps.uninstallService()
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
87
agent/src/config/agentConfig.ts
Normal file
87
agent/src/config/agentConfig.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Agent configuration — PLAN_RELAY_AGENT T2. Zod-validated at the startup boundary (INV9):
|
||||||
|
* - relayUrl MUST be wss:// (encrypted tunnel only)
|
||||||
|
* - enrollUrl MUST be https:// (enrollment over TLS only)
|
||||||
|
* - localTargetUrl MUST be ws:// to a LOOPBACK host (anti-SSRF: the agent forwards ONLY to
|
||||||
|
* the local web-terminal, never an arbitrary target).
|
||||||
|
* Fail-fast on missing/invalid values — never silently default a security-relevant field.
|
||||||
|
*/
|
||||||
|
import { homedir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export interface AgentConfig {
|
||||||
|
readonly relayUrl: string
|
||||||
|
readonly enrollUrl: string
|
||||||
|
readonly stateDir: string
|
||||||
|
readonly localTargetUrl: string
|
||||||
|
readonly subdomain: string | null
|
||||||
|
readonly hostId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOOPBACK_HOSTNAMES: readonly string[] = ['localhost', '127.0.0.1', '::1', '[::1]']
|
||||||
|
|
||||||
|
/** True iff `url` is ws:// to a loopback host (127.0.0.0/8, localhost, or ::1). */
|
||||||
|
export function isLoopbackWsUrl(url: string): boolean {
|
||||||
|
let parsed: URL
|
||||||
|
try {
|
||||||
|
parsed = new URL(url)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'ws:') return false
|
||||||
|
const host = parsed.hostname
|
||||||
|
return LOOPBACK_HOSTNAMES.includes(host) || host.startsWith('127.')
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasScheme(url: string, scheme: string): boolean {
|
||||||
|
try {
|
||||||
|
return new URL(url).protocol === scheme
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AgentConfigSchema = z
|
||||||
|
.object({
|
||||||
|
relayUrl: z
|
||||||
|
.string()
|
||||||
|
.refine((u) => hasScheme(u, 'wss:'), 'relayUrl must be a wss:// URL'),
|
||||||
|
enrollUrl: z
|
||||||
|
.string()
|
||||||
|
.refine((u) => hasScheme(u, 'https:'), 'enrollUrl must be an https:// URL'),
|
||||||
|
stateDir: z.string().min(1),
|
||||||
|
localTargetUrl: z
|
||||||
|
.string()
|
||||||
|
.refine(isLoopbackWsUrl, 'localTargetUrl must be a ws:// loopback URL (anti-SSRF)'),
|
||||||
|
subdomain: z.string().min(1).nullable(),
|
||||||
|
hostId: z.string().min(1).nullable(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.readonly()
|
||||||
|
|
||||||
|
const DEFAULT_LOCAL_TARGET = 'ws://127.0.0.1:3000'
|
||||||
|
|
||||||
|
/** Default state dir where key/cert/content-secret live (~/.web-terminal-agent). */
|
||||||
|
export function defaultStateDir(): string {
|
||||||
|
return join(homedir(), '.web-terminal-agent')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build + validate an AgentConfig from env + explicit argv overrides (argv wins). Throws a
|
||||||
|
* ZodError (fail-fast) if any required field is missing or fails a scheme/loopback refinement.
|
||||||
|
*/
|
||||||
|
export function loadAgentConfig(
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
argv: Partial<AgentConfig> = {},
|
||||||
|
): AgentConfig {
|
||||||
|
const merged = {
|
||||||
|
relayUrl: argv.relayUrl ?? env.RELAY_URL,
|
||||||
|
enrollUrl: argv.enrollUrl ?? env.ENROLL_URL,
|
||||||
|
stateDir: argv.stateDir ?? env.STATE_DIR ?? defaultStateDir(),
|
||||||
|
localTargetUrl: argv.localTargetUrl ?? env.LOCAL_TARGET_URL ?? DEFAULT_LOCAL_TARGET,
|
||||||
|
subdomain: argv.subdomain ?? env.SUBDOMAIN ?? null,
|
||||||
|
hostId: argv.hostId ?? env.HOST_ID ?? null,
|
||||||
|
}
|
||||||
|
return AgentConfigSchema.parse(merged)
|
||||||
|
}
|
||||||
144
agent/src/e2e/hostEndpoint.ts
Normal file
144
agent/src/e2e/hostEndpoint.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* Host E2E endpoint (§4.4) — PLAN_RELAY_AGENT T15. The HOST side of the authenticated ECDH: absorb
|
||||||
|
* ClientHello → verify the device-auth-proof FIRST → reply HostHello → derive DirectionalKeys →
|
||||||
|
* per-stream seal(h2c)/open(c2h). Distinct from live frames, every replay-bound output is ALSO
|
||||||
|
* sealed under K_content via the T19 ReplaySealer (FIX 3).
|
||||||
|
*
|
||||||
|
* ANTI-MITM (INV2): the device-auth-proof `verifyDeviceProof` is P5-OWNED and reaches this module
|
||||||
|
* INJECTED (FIX 6b) — this file MUST NOT `import { verifyDeviceAuthProof } from 'relay-e2e'`. The
|
||||||
|
* proof is verified BEFORE any key derivation; a forged proof ⇒ MitmAbortError, NO HostHello, NO
|
||||||
|
* DirectionalKeys. A no-stub guard test asserts this file imports no verifier and that swapping the
|
||||||
|
* injected verifier for `async () => true` makes the MITM test fail.
|
||||||
|
*
|
||||||
|
* BLOCKING GATE (open Q#2 — DEFERRED): the real §4.4 crypto (`sealFrame`/`openFrame`/
|
||||||
|
* `createE2ESession`) + host-handshake wiring (`createHostHandshake`) live in P4 `relay-e2e/`, and
|
||||||
|
* the P5 verifier + forged-proof vector are a hard pre-W4 gate. P4 is NOT built yet, so those are
|
||||||
|
* INJECTED here as seams typed to the frozen relay-contracts signatures — production passes the
|
||||||
|
* relay-e2e impls verbatim, NEVER a stub. INV11: even after open(), bytes go to loopback OPAQUE.
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
ClientHello,
|
||||||
|
E2ESession,
|
||||||
|
HandshakeResult,
|
||||||
|
HostHello,
|
||||||
|
} from 'relay-contracts'
|
||||||
|
import type { AgentIdentity } from '../keys/identity.js'
|
||||||
|
import type { FrameTransform } from '../transport/streamRouter.js'
|
||||||
|
import type { ReplaySealer } from './replaySeal.js'
|
||||||
|
|
||||||
|
/** Host-side device-proof verifier — INJECTED (P5 issues+verifies), bound to the ClientHello. */
|
||||||
|
export type VerifyDeviceProof = (
|
||||||
|
proof: string,
|
||||||
|
binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array },
|
||||||
|
) => Promise<boolean>
|
||||||
|
|
||||||
|
export class MitmAbortError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'MitmAbortError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** P4 host-handshake wiring seam (`createHostHandshake`), typed to §4.4 shapes. */
|
||||||
|
export interface HostHandshake {
|
||||||
|
respond(clientHello: ClientHello): Promise<{ hello: HostHello; result: HandshakeResult }>
|
||||||
|
}
|
||||||
|
export type CreateHostHandshake = (deps: {
|
||||||
|
verifyDeviceProof: VerifyDeviceProof
|
||||||
|
identity: AgentIdentity
|
||||||
|
}) => HostHandshake
|
||||||
|
|
||||||
|
/** P4 §4.4 crypto core, injected (impls live in relay-e2e/). */
|
||||||
|
export interface E2ECryptoDeps {
|
||||||
|
createHostHandshake: CreateHostHandshake
|
||||||
|
createE2ESession(role: 'host', result: HandshakeResult): E2ESession
|
||||||
|
/** Wire codec for the HostHello reply (P4/P6-owned framing). */
|
||||||
|
encodeHostHello(hello: HostHello): Uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produce the HostHello + HandshakeResult for a ClientHello. The proof is verified FIRST; a forged
|
||||||
|
* proof aborts with MitmAbortError and NO key derivation (anti-MITM). FIX 2: the result carries
|
||||||
|
* DirectionalKeys{c2h,h2c}, never a single sessionKey.
|
||||||
|
*/
|
||||||
|
export async function makeHostHello(
|
||||||
|
clientHello: ClientHello,
|
||||||
|
id: AgentIdentity,
|
||||||
|
verifyDeviceProof: VerifyDeviceProof,
|
||||||
|
deps: Pick<E2ECryptoDeps, 'createHostHandshake'>,
|
||||||
|
): Promise<{ hello: HostHello; result: HandshakeResult }> {
|
||||||
|
const ok = await verifyDeviceProof(clientHello.deviceAuthProof, {
|
||||||
|
clientEphPub: clientHello.clientEphPub,
|
||||||
|
clientNonce: clientHello.clientNonce,
|
||||||
|
})
|
||||||
|
if (!ok) {
|
||||||
|
throw new MitmAbortError('device-auth-proof verification failed — aborting, no keys derived')
|
||||||
|
}
|
||||||
|
const handshake = deps.createHostHandshake({ verifyDeviceProof, identity: id })
|
||||||
|
return handshake.respond(clientHello)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FrameTransform + a `seedSession` seam. The wiring layer intercepts the first (ClientHello) frame,
|
||||||
|
* runs `makeHostHello` (async, verify-first), then calls `seedSession` with the derived
|
||||||
|
* E2ESession + the encoded HostHello (queued as a control frame the router flushes upstream).
|
||||||
|
* After seeding: inbound = session.open(c2h) → loopback (OPAQUE, INV11); outbound = session.seal
|
||||||
|
* (h2c) → tunnel AND replay.seal(K_content) — two DISTINCT ciphertexts (FIX 3).
|
||||||
|
*/
|
||||||
|
export interface E2ETransform extends FrameTransform {
|
||||||
|
seedSession(streamId: number, session: E2ESession, hostHelloBytes: Uint8Array): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StreamE2EState {
|
||||||
|
session: E2ESession | null
|
||||||
|
readonly control: Uint8Array[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createE2ETransform(
|
||||||
|
_id: AgentIdentity,
|
||||||
|
_verifyDeviceProof: VerifyDeviceProof,
|
||||||
|
replay: ReplaySealer,
|
||||||
|
): E2ETransform {
|
||||||
|
const streams = new Map<number, StreamE2EState>()
|
||||||
|
|
||||||
|
function stateFor(streamId: number): StreamE2EState {
|
||||||
|
let s = streams.get(streamId)
|
||||||
|
if (s === undefined) {
|
||||||
|
s = { session: null, control: [] }
|
||||||
|
streams.set(streamId, s)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openStream(streamId: number): void {
|
||||||
|
stateFor(streamId)
|
||||||
|
},
|
||||||
|
closeStream(streamId: number): void {
|
||||||
|
streams.delete(streamId)
|
||||||
|
},
|
||||||
|
seedSession(streamId: number, session: E2ESession, hostHelloBytes: Uint8Array): void {
|
||||||
|
const s = stateFor(streamId)
|
||||||
|
s.session = session
|
||||||
|
s.control.push(hostHelloBytes)
|
||||||
|
},
|
||||||
|
inbound(streamId: number, cipher: Uint8Array): Uint8Array | null {
|
||||||
|
const s = stateFor(streamId)
|
||||||
|
if (s.session === null) return null // handshake not yet seeded; wiring layer handles it
|
||||||
|
return s.session.open(cipher) // opaque plaintext to loopback (INV11)
|
||||||
|
},
|
||||||
|
outbound(streamId: number, plain: Uint8Array): Uint8Array {
|
||||||
|
const s = stateFor(streamId)
|
||||||
|
if (s.session === null) {
|
||||||
|
throw new MitmAbortError('cannot seal before the E2E session is established')
|
||||||
|
}
|
||||||
|
replay.seal(plain) // FIX 3: recoverable K_content seal, DISTINCT from the live h2c frame
|
||||||
|
return s.session.seal(plain)
|
||||||
|
},
|
||||||
|
takeControlFrames(streamId: number): Uint8Array[] {
|
||||||
|
const s = streams.get(streamId)
|
||||||
|
if (s === undefined || s.control.length === 0) return []
|
||||||
|
return s.control.splice(0, s.control.length)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
49
agent/src/e2e/replaySeal.ts
Normal file
49
agent/src/e2e/replaySeal.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Replay-frame sealer (recoverable K_content) — PLAN_RELAY_AGENT T19 (FIX 3).
|
||||||
|
*
|
||||||
|
* Live host→client frames use the EPHEMERAL DirectionalKeys.h2c (forward-secret, gone after
|
||||||
|
* reconnect). But "refresh the page and the Claude session is still there" needs the ring-buffer /
|
||||||
|
* preview ciphertext to be RECOVERABLE — so every replay-bound output is ALSO sealed under the
|
||||||
|
* host-scoped recoverable K_content = deriveContentKey({ hostContentSecret, sessionId, alg }),
|
||||||
|
* DISTINCT from the live h2c frame. The browser re-derives the identical K_content (P5) and opens
|
||||||
|
* it (P6). This is the single agent-side consumer of the FIX 3 recoverable key.
|
||||||
|
*
|
||||||
|
* INTEGRATION SEAM: the frozen §4.4 replay-crypto IMPLEMENTATIONS live in P4 `relay-e2e/`
|
||||||
|
* (`deriveContentKey`, `sealReplayFrame`). P4 is not built yet, so they are INJECTED here typed to
|
||||||
|
* the frozen relay-contracts signatures — production wiring passes the relay-e2e impls verbatim.
|
||||||
|
* `hostContentSecret` comes from Keystore.loadContentSecret() (T3); NEVER the ephemeral key,
|
||||||
|
* NEVER logged, NEVER sent to the relay (INV2/INV9).
|
||||||
|
*/
|
||||||
|
import type { AeadAlg, AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts'
|
||||||
|
|
||||||
|
/** The two §4.4 replay primitives, typed to the frozen relay-contracts signatures (P4 impls). */
|
||||||
|
export interface ReplayCrypto {
|
||||||
|
deriveContentKey(params: ReplayKeyParams): AeadKey
|
||||||
|
sealReplayFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplaySealer {
|
||||||
|
/** K_content seal with monotonic seq per session (INV13); NOT the live h2c frame. */
|
||||||
|
seal(plaintext: Uint8Array): E2EEnvelope
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a per-(host, session) replay sealer. K_content is derived ONCE from
|
||||||
|
* { hostContentSecret, sessionId, alg }; seq is strictly monotonic from 0 (INV13).
|
||||||
|
*/
|
||||||
|
export function createReplaySealer(
|
||||||
|
hostContentSecret: Uint8Array,
|
||||||
|
sessionId: string,
|
||||||
|
alg: AeadAlg,
|
||||||
|
crypto: ReplayCrypto,
|
||||||
|
): ReplaySealer {
|
||||||
|
const key = crypto.deriveContentKey({ hostContentSecret, sessionId, alg })
|
||||||
|
let seq = 0n
|
||||||
|
return {
|
||||||
|
seal(plaintext: Uint8Array): E2EEnvelope {
|
||||||
|
const env = crypto.sealReplayFrame(key, seq, plaintext)
|
||||||
|
seq += 1n
|
||||||
|
return env
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
96
agent/src/enroll/csr.ts
Normal file
96
agent/src/enroll/csr.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* PKCS#10 CSR over the Ed25519 identity — PLAN_RELAY_AGENT T4.
|
||||||
|
*
|
||||||
|
* Node has no built-in CSR generator, so this is a compact, self-contained DER encoder that
|
||||||
|
* emits a standard PKCS#10 CertificationRequest signed with the in-process Ed25519 key (INV4 —
|
||||||
|
* only the PUBLIC key + a signature leave; the private key is never serialized here).
|
||||||
|
*
|
||||||
|
* NOTE (cross-plan / open Q#1): the exact SPIFFE-style cert PROFILE (SAN = subdomain vs a SPIFFE
|
||||||
|
* URI) is set by P3's CA. This builds the standard subject-CN PKCS#10 shape; when P3 freezes its
|
||||||
|
* profile, extend `buildCsr`'s attributes here. That is the single integration point.
|
||||||
|
*/
|
||||||
|
import type { AgentIdentity } from '../keys/identity.js'
|
||||||
|
|
||||||
|
// --- minimal DER encoding helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return Uint8Array.from([0x80 | bytes.length, ...bytes])
|
||||||
|
}
|
||||||
|
|
||||||
|
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 total = chunks.reduce((s, c) => s + c.length, 0)
|
||||||
|
const out = new Uint8Array(total)
|
||||||
|
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
|
||||||
|
|
||||||
|
// OID 2.5.4.3 (commonName) and 1.3.101.112 (Ed25519) as pre-encoded DER value bytes.
|
||||||
|
const OID_CN = Uint8Array.from([0x55, 0x04, 0x03])
|
||||||
|
const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70])
|
||||||
|
|
||||||
|
/** SubjectPublicKeyInfo DER for a raw Ed25519 public key (fixed 44-byte structure). */
|
||||||
|
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]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** X.501 Name with a single CN=<subject> RDN. */
|
||||||
|
function nameFromCn(cn: string): Uint8Array {
|
||||||
|
const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))]))
|
||||||
|
const rdn = tlv(SET, atv)
|
||||||
|
return tlv(SEQUENCE, rdn)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPem(der: Uint8Array, label: string): string {
|
||||||
|
const b64 = Buffer.from(der).toString('base64')
|
||||||
|
const lines = b64.match(/.{1,64}/g) ?? []
|
||||||
|
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a PKCS#10 CSR (PEM) for `id` with subject CN=`subject`, signed by the Ed25519 key.
|
||||||
|
* The private key is used in-process only; never serialized into the output (INV4).
|
||||||
|
*/
|
||||||
|
export function buildCsr(id: AgentIdentity, subject: string): string {
|
||||||
|
const version = tlv(INTEGER, Uint8Array.from([0x00]))
|
||||||
|
const name = nameFromCn(subject)
|
||||||
|
const spki = spkiFromRawEd25519(id.publicKey)
|
||||||
|
const attributes = tlv(CONTEXT_0, new Uint8Array(0)) // [0] IMPLICIT empty SET OF Attribute
|
||||||
|
const requestInfo = tlv(SEQUENCE, concat([version, name, spki, attributes]))
|
||||||
|
|
||||||
|
const signature = id.sign(requestInfo) // Ed25519 over CertificationRequestInfo
|
||||||
|
const sigAlg = tlv(SEQUENCE, tlv(OID, OID_ED25519))
|
||||||
|
const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature]))
|
||||||
|
|
||||||
|
const csr = tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits]))
|
||||||
|
return toPem(csr, 'CERTIFICATE REQUEST')
|
||||||
|
}
|
||||||
137
agent/src/enroll/pair.ts
Normal file
137
agent/src/enroll/pair.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* §4.5 pairing-code redemption (agent side) — PLAN_RELAY_AGENT T4.
|
||||||
|
*
|
||||||
|
* REDEEM: POST /enroll { code, agentPubkey, csr } (raw code NEVER persisted — INV5).
|
||||||
|
* RETURN: the FROZEN EnrollResult (imported from relay-contracts, never redeclared), validated
|
||||||
|
* with EnrollResultSchema at the boundary (untrusted external data). On success the FIX 3
|
||||||
|
* `hostContentSecret` (wrapped to this agent's Ed25519 identity by P3) is UNWRAPPED in-process
|
||||||
|
* and persisted 0600 via the keystore; the WRAPPED bytes are never stored, the unwrapped secret
|
||||||
|
* is never logged/re-sent (INV5/INV9).
|
||||||
|
*
|
||||||
|
* Only the PUBLIC key + CSR leave the host (INV4).
|
||||||
|
*/
|
||||||
|
import { EnrollResultSchema, decodeBase64UrlBytes, encodeBase64UrlBytes } from 'relay-contracts'
|
||||||
|
import type { EnrollResult } from 'relay-contracts'
|
||||||
|
import type { AgentIdentity } from '../keys/identity.js'
|
||||||
|
import type { Keystore } from '../keys/keystore.js'
|
||||||
|
import { buildCsr } from './csr.js'
|
||||||
|
|
||||||
|
/** v0.8 shared-token gate vs v0.9+ per-host Ed25519. Default is `'ed25519'` from v0.9. */
|
||||||
|
export type EnrollMode = 'token' | 'ed25519'
|
||||||
|
export const DEFAULT_ENROLL_MODE: EnrollMode = 'ed25519'
|
||||||
|
|
||||||
|
export class EnrollError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'EnrollError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class PairingCodeSpentError extends EnrollError {
|
||||||
|
constructor() {
|
||||||
|
super('pairing code already redeemed (single-use)')
|
||||||
|
this.name = 'PairingCodeSpentError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class PairingCodeExpiredError extends EnrollError {
|
||||||
|
constructor() {
|
||||||
|
super('pairing code expired')
|
||||||
|
this.name = 'PairingCodeExpiredError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unwrap the P3-wrapped `hostContentSecret` using the agent's enrollment identity (in-process).
|
||||||
|
* P3's exact wrap scheme is not yet frozen (cross-plan integration point); this seam is injected
|
||||||
|
* so the real unwrap drops in without touching the redeem flow. Default = passthrough.
|
||||||
|
*/
|
||||||
|
export type UnwrapContentSecret = (wrapped: Uint8Array, id: AgentIdentity) => Uint8Array
|
||||||
|
const passthroughUnwrap: UnwrapContentSecret = (wrapped) => wrapped
|
||||||
|
|
||||||
|
export interface RedeemOptions {
|
||||||
|
readonly fetchImpl?: typeof fetch
|
||||||
|
readonly mode?: EnrollMode
|
||||||
|
readonly agentToken?: string
|
||||||
|
readonly unwrapContentSecret?: UnwrapContentSecret
|
||||||
|
readonly subject?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnrollResponseJson {
|
||||||
|
hostId: string
|
||||||
|
subdomain: string
|
||||||
|
cert: string
|
||||||
|
caChain: string
|
||||||
|
hostContentSecret: string // base64url over the wire
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnrollResult(json: unknown): EnrollResult {
|
||||||
|
const j = json as Partial<EnrollResponseJson>
|
||||||
|
if (typeof j.hostContentSecret !== 'string') {
|
||||||
|
throw new EnrollError('enroll response missing hostContentSecret')
|
||||||
|
}
|
||||||
|
const candidate = {
|
||||||
|
hostId: j.hostId,
|
||||||
|
subdomain: j.subdomain,
|
||||||
|
cert: j.cert,
|
||||||
|
caChain: j.caChain,
|
||||||
|
hostContentSecret: decodeBase64UrlBytes(j.hostContentSecret),
|
||||||
|
}
|
||||||
|
const result = EnrollResultSchema.safeParse(candidate)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new EnrollError(`enroll response failed schema validation: ${result.error.message}`)
|
||||||
|
}
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redeem `code` at `enrollUrl`. Sends only pubkey + CSR (INV4); never persists the raw code
|
||||||
|
* (INV5). Stores the returned cert + CA chain and the unwrapped hostContentSecret 0600.
|
||||||
|
*/
|
||||||
|
export async function redeemPairingCode(
|
||||||
|
enrollUrl: string,
|
||||||
|
code: string,
|
||||||
|
id: AgentIdentity,
|
||||||
|
ks: Keystore,
|
||||||
|
opts: RedeemOptions = {},
|
||||||
|
): Promise<EnrollResult> {
|
||||||
|
const doFetch = opts.fetchImpl ?? fetch
|
||||||
|
const mode = opts.mode ?? DEFAULT_ENROLL_MODE
|
||||||
|
const unwrap = opts.unwrapContentSecret ?? passthroughUnwrap
|
||||||
|
|
||||||
|
const body =
|
||||||
|
mode === 'token'
|
||||||
|
? { code, agentToken: opts.agentToken ?? '' }
|
||||||
|
: {
|
||||||
|
code,
|
||||||
|
agentPubkey: encodeBase64UrlBytes(id.publicKey),
|
||||||
|
csr: buildCsr(id, opts.subject ?? 'web-terminal-agent'),
|
||||||
|
}
|
||||||
|
|
||||||
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await doFetch(enrollUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
throw new EnrollError(`enroll request failed: ${(err as Error).message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 409) throw new PairingCodeSpentError()
|
||||||
|
if (res.status === 410) throw new PairingCodeExpiredError()
|
||||||
|
if (!res.ok) throw new EnrollError(`enroll returned HTTP ${res.status}`)
|
||||||
|
|
||||||
|
let json: unknown
|
||||||
|
try {
|
||||||
|
json = await res.json()
|
||||||
|
} catch (err) {
|
||||||
|
throw new EnrollError(`enroll response was not JSON: ${(err as Error).message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const enroll = parseEnrollResult(json)
|
||||||
|
ks.saveCert(enroll.cert, enroll.caChain)
|
||||||
|
// FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored).
|
||||||
|
const unwrapped = unwrap(enroll.hostContentSecret, id)
|
||||||
|
ks.saveContentSecret(unwrapped)
|
||||||
|
return enroll
|
||||||
|
}
|
||||||
32
agent/src/index.ts
Normal file
32
agent/src/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* web-terminal-agent (P2) — public barrel. The host agent: Ed25519 identity, §4.5 pairing, the
|
||||||
|
* §4.1 mux tunnel + loopback splice, mTLS dial + cert rotation, fast revocation, and the §4.4 E2E
|
||||||
|
* endpoint. Imports the FROZEN shared surface from relay-contracts read-only; wires P4 relay-e2e
|
||||||
|
* crypto via injected seams (see docs/PLAN_RELAY_AGENT.md).
|
||||||
|
*/
|
||||||
|
export * from './config/agentConfig.js'
|
||||||
|
export * from './log/logger.js'
|
||||||
|
export * from './transport/seams.js'
|
||||||
|
export * from './keys/identity.js'
|
||||||
|
export * from './keys/keystore.js'
|
||||||
|
export * from './enroll/csr.js'
|
||||||
|
export * from './enroll/pair.js'
|
||||||
|
export { parseArgs, runCli, CliUsageError } from './cli.js'
|
||||||
|
export type { CliArgs, CliCommand, CliDeps } from './cli.js'
|
||||||
|
export * from './transport/frpScaffold.js'
|
||||||
|
export * from './transport/tunnel.js'
|
||||||
|
export * from './transport/loopback.js'
|
||||||
|
export * from './transport/streamRouter.js'
|
||||||
|
export * from './transport/heartbeat.js'
|
||||||
|
export * from './transport/backoff.js'
|
||||||
|
export * from './transport/flowControl.js'
|
||||||
|
export * from './transport/dial.js'
|
||||||
|
export * from './certs/rotation.js'
|
||||||
|
export * from './lifecycle/revocation.js'
|
||||||
|
export * from './e2e/replaySeal.js'
|
||||||
|
export * from './e2e/hostEndpoint.js'
|
||||||
|
export * from './dist/buildBinary.js'
|
||||||
|
export * from './service/install.js'
|
||||||
|
export * from './service/launchd.js'
|
||||||
|
export * from './service/systemd.js'
|
||||||
|
export * from './service/originConfig.js'
|
||||||
83
agent/src/keys/identity.ts
Normal file
83
agent/src/keys/identity.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Agent identity — PLAN_RELAY_AGENT T3 (INV4: the private key NEVER leaves the host).
|
||||||
|
*
|
||||||
|
* Ed25519 keypair generated locally with node:crypto. `AgentIdentity` exposes ONLY the public
|
||||||
|
* key, the §4.2 enroll fingerprint, and an in-process `sign()`; there is NO API that returns or
|
||||||
|
* serializes the private key. The raw private key material is held in a module-private closure.
|
||||||
|
*/
|
||||||
|
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto'
|
||||||
|
import type { KeyObject } from 'node:crypto'
|
||||||
|
import { encodeBase64UrlBytes } from 'relay-contracts'
|
||||||
|
|
||||||
|
export interface AgentIdentity {
|
||||||
|
/** Ed25519 raw 32-byte public key → stored in host registry (§4.2 agent_pubkey). */
|
||||||
|
readonly publicKey: Uint8Array
|
||||||
|
/** §4.2 enroll_fpr: base64url(SHA-256(publicKey)) — pinned by the browser E2E TOFU (§4.4). */
|
||||||
|
readonly enrollFpr: string
|
||||||
|
/** Ed25519 signature over `message`, using the in-process private key. Never returns the key. */
|
||||||
|
sign(message: Uint8Array): Uint8Array
|
||||||
|
/** Export the PRIVATE key as PKCS#8 PEM — for on-disk 0600 persistence ONLY (keystore). */
|
||||||
|
exportPrivatePkcs8Pem(): string
|
||||||
|
/** The underlying private KeyObject — for in-process crypto (CSR/mTLS) ONLY, never serialized to the wire. */
|
||||||
|
privateKeyObject(): KeyObject
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SHA-256 fingerprint of a raw Ed25519 public key, base64url — §4.2 enroll_fpr. */
|
||||||
|
export function computeEnrollFpr(publicKey: Uint8Array): string {
|
||||||
|
const digest = createHash('sha256').update(publicKey).digest()
|
||||||
|
return encodeBase64UrlBytes(new Uint8Array(digest))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw 32-byte Ed25519 public key from a KeyObject (strips the SPKI DER prefix). */
|
||||||
|
function rawPublicKey(pub: KeyObject): Uint8Array {
|
||||||
|
const der = pub.export({ type: 'spki', format: 'der' })
|
||||||
|
// Ed25519 SPKI is a fixed 44-byte structure; the raw key is the trailing 32 bytes.
|
||||||
|
return new Uint8Array(der.subarray(der.length - 32))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIdentity(privateKey: KeyObject, publicKey: KeyObject): AgentIdentity {
|
||||||
|
const rawPub = rawPublicKey(publicKey)
|
||||||
|
const enrollFpr = computeEnrollFpr(rawPub)
|
||||||
|
return {
|
||||||
|
publicKey: rawPub,
|
||||||
|
enrollFpr,
|
||||||
|
sign(message: Uint8Array): Uint8Array {
|
||||||
|
return new Uint8Array(sign(null, message, privateKey))
|
||||||
|
},
|
||||||
|
exportPrivatePkcs8Pem(): string {
|
||||||
|
return privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
|
||||||
|
},
|
||||||
|
privateKeyObject(): KeyObject {
|
||||||
|
return privateKey
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a fresh Ed25519 identity; the private key is held in-memory only (INV4). */
|
||||||
|
export function generateIdentity(): AgentIdentity {
|
||||||
|
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
|
||||||
|
return buildIdentity(privateKey, publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconstruct an identity from a stored PKCS#8 PEM private key (keystore load path). */
|
||||||
|
export function identityFromPrivatePem(pem: string): AgentIdentity {
|
||||||
|
const privateKey = createPrivateKey(pem)
|
||||||
|
const publicKey = createPublicKey(privateKey)
|
||||||
|
return buildIdentity(privateKey, publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify an Ed25519 signature against a raw public key — helper for tests/handshake checks. */
|
||||||
|
export function verifySignature(
|
||||||
|
publicKey: Uint8Array,
|
||||||
|
message: Uint8Array,
|
||||||
|
signature: Uint8Array,
|
||||||
|
): boolean {
|
||||||
|
const spkiPrefix = Uint8Array.from([
|
||||||
|
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
||||||
|
])
|
||||||
|
const der = new Uint8Array(spkiPrefix.length + publicKey.length)
|
||||||
|
der.set(spkiPrefix, 0)
|
||||||
|
der.set(publicKey, spkiPrefix.length)
|
||||||
|
const pub = createPublicKey({ key: Buffer.from(der), format: 'der', type: 'spki' })
|
||||||
|
return verify(null, message, pub, signature)
|
||||||
|
}
|
||||||
109
agent/src/keys/keystore.ts
Normal file
109
agent/src/keys/keystore.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* On-disk keystore — PLAN_RELAY_AGENT T3 (INV4/INV5). Persists ONLY to `stateDir`, every secret
|
||||||
|
* file mode `0600`. Stores: the Ed25519 private key (PKCS#8 PEM), the mTLS cert + CA chain, and
|
||||||
|
* the FIX 3 unwrapped `hostContentSecret`. The raw pairing code is NEVER written here (T4).
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
chmodSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
statSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import type { AgentIdentity } from './identity.js'
|
||||||
|
import { identityFromPrivatePem } from './identity.js'
|
||||||
|
|
||||||
|
const SECRET_MODE = 0o600
|
||||||
|
const DIR_MODE = 0o700
|
||||||
|
|
||||||
|
export interface Keystore {
|
||||||
|
saveIdentity(id: AgentIdentity): void
|
||||||
|
loadIdentity(): AgentIdentity | null
|
||||||
|
saveCert(certPem: string, caChainPem: string): void
|
||||||
|
loadCert(): { certPem: string; caChainPem: string } | null
|
||||||
|
saveContentSecret(secret: Uint8Array): void
|
||||||
|
loadContentSecret(): Uint8Array | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY_FILE = 'agent.key.pem'
|
||||||
|
const CERT_FILE = 'agent.cert.pem'
|
||||||
|
const CA_FILE = 'agent.ca.pem'
|
||||||
|
const CONTENT_SECRET_FILE = 'content.secret'
|
||||||
|
|
||||||
|
/** Corrupt/unreadable key material surfaces as a typed error (never a silent empty read). */
|
||||||
|
export class KeystoreError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'KeystoreError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDir(stateDir: string): void {
|
||||||
|
if (!existsSync(stateDir)) {
|
||||||
|
mkdirSync(stateDir, { recursive: true, mode: DIR_MODE })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Refuse to write secrets into a world-/group-accessible directory (INV5 hard error).
|
||||||
|
const mode = statSync(stateDir).mode & 0o777
|
||||||
|
if ((mode & 0o077) !== 0) {
|
||||||
|
throw new KeystoreError(
|
||||||
|
`stateDir ${stateDir} is group/world accessible (mode ${mode.toString(8)}); refusing to write secrets`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSecret(path: string, data: string | Uint8Array): void {
|
||||||
|
writeFileSync(path, data, { mode: SECRET_MODE })
|
||||||
|
// Enforce 0600 even if a prior umask/file left it wider.
|
||||||
|
chmodSync(path, SECRET_MODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openKeystore(stateDir: string): Keystore {
|
||||||
|
const keyPath = join(stateDir, KEY_FILE)
|
||||||
|
const certPath = join(stateDir, CERT_FILE)
|
||||||
|
const caPath = join(stateDir, CA_FILE)
|
||||||
|
const secretPath = join(stateDir, CONTENT_SECRET_FILE)
|
||||||
|
|
||||||
|
return {
|
||||||
|
saveIdentity(id: AgentIdentity): void {
|
||||||
|
ensureDir(stateDir)
|
||||||
|
writeSecret(keyPath, id.exportPrivatePkcs8Pem())
|
||||||
|
},
|
||||||
|
loadIdentity(): AgentIdentity | null {
|
||||||
|
if (!existsSync(keyPath)) return null
|
||||||
|
let pem: string
|
||||||
|
try {
|
||||||
|
pem = readFileSync(keyPath, 'utf8')
|
||||||
|
} catch (err) {
|
||||||
|
throw new KeystoreError(`failed to read identity key: ${(err as Error).message}`)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return identityFromPrivatePem(pem)
|
||||||
|
} catch (err) {
|
||||||
|
throw new KeystoreError(`corrupt identity key file: ${(err as Error).message}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
saveCert(certPem: string, caChainPem: string): void {
|
||||||
|
ensureDir(stateDir)
|
||||||
|
writeSecret(certPath, certPem)
|
||||||
|
writeSecret(caPath, caChainPem)
|
||||||
|
},
|
||||||
|
loadCert(): { certPem: string; caChainPem: string } | null {
|
||||||
|
if (!existsSync(certPath) || !existsSync(caPath)) return null
|
||||||
|
return {
|
||||||
|
certPem: readFileSync(certPath, 'utf8'),
|
||||||
|
caChainPem: readFileSync(caPath, 'utf8'),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
saveContentSecret(secret: Uint8Array): void {
|
||||||
|
ensureDir(stateDir)
|
||||||
|
writeSecret(secretPath, secret)
|
||||||
|
},
|
||||||
|
loadContentSecret(): Uint8Array | null {
|
||||||
|
if (!existsSync(secretPath)) return null
|
||||||
|
return new Uint8Array(readFileSync(secretPath))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
68
agent/src/lifecycle/revocation.ts
Normal file
68
agent/src/lifecycle/revocation.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Revocation + GOAWAY teardown — PLAN_RELAY_AGENT T14 (INV12). Distinguishes a `revoked` GOAWAY
|
||||||
|
* (immediate teardown, NO reconnect) from the graceful `operatorDrain`/`shutdown` reasons
|
||||||
|
* (finish in-flight, reconnect elsewhere) using ONLY the FROZEN §4.1 3-value `GoAwayReason` +
|
||||||
|
* `decodeGoAwayReason` — never a locally-invented numeric mapping (cross-plan drift = silent
|
||||||
|
* INV12 defeat). An unknown numeric code FAILS CLOSED to `revoked`.
|
||||||
|
*
|
||||||
|
* The RevocationState seam is imported from transport/seams.ts (W0), not redeclared. Its
|
||||||
|
* `isRevoked()` is what T10's reconnectLoop consumes (one-way edge, no task cycle).
|
||||||
|
*/
|
||||||
|
import { decodeGoAwayReason } from 'relay-contracts'
|
||||||
|
import type { GoAwayReason } from 'relay-contracts'
|
||||||
|
import type { RevocationState, RevokeReason } from '../transport/seams.js'
|
||||||
|
|
||||||
|
export type GoAwayAction = 'drain' | 'revoked'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure exhaustive map over the FROZEN 3-value union. `operatorDrain`/`shutdown` → drain (reconnect
|
||||||
|
* elsewhere); `revoked` → revoked (stop). No integer literals — the reason is already a label.
|
||||||
|
*/
|
||||||
|
export function classifyGoAway(reason: GoAwayReason): GoAwayAction {
|
||||||
|
switch (reason) {
|
||||||
|
case 'operatorDrain':
|
||||||
|
case 'shutdown':
|
||||||
|
return 'drain'
|
||||||
|
case 'revoked':
|
||||||
|
return 'revoked'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRevocationState(onTeardown: () => void): RevocationState {
|
||||||
|
let revoked = false
|
||||||
|
return {
|
||||||
|
isRevoked(): boolean {
|
||||||
|
return revoked
|
||||||
|
},
|
||||||
|
markRevoked(_reason: RevokeReason): void {
|
||||||
|
if (revoked) return
|
||||||
|
revoked = true
|
||||||
|
onTeardown() // close all streams + tunnel immediately
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a decoded GOAWAY reason to the revocation state. On `revoked`, marks the state revoked
|
||||||
|
* (⇒ teardown + reconnect suppression). Returns the action taken.
|
||||||
|
*/
|
||||||
|
export function applyGoAway(reason: GoAwayReason, state: RevocationState): GoAwayAction {
|
||||||
|
const action = classifyGoAway(reason)
|
||||||
|
if (action === 'revoked') state.markRevoked('goaway-revoked')
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a raw GOAWAY wire code. Decodes via the FROZEN decoder; an UNKNOWN code (decoder throws)
|
||||||
|
* FAILS CLOSED to `revoked` (a default that guessed "drain" would defeat INV12).
|
||||||
|
*/
|
||||||
|
export function applyGoAwayCode(code: number, state: RevocationState): GoAwayAction {
|
||||||
|
let reason: GoAwayReason
|
||||||
|
try {
|
||||||
|
reason = decodeGoAwayReason(code)
|
||||||
|
} catch {
|
||||||
|
state.markRevoked('goaway-revoked') // fail closed
|
||||||
|
return 'revoked'
|
||||||
|
}
|
||||||
|
return applyGoAway(reason, state)
|
||||||
|
}
|
||||||
75
agent/src/log/logger.ts
Normal file
75
agent/src/log/logger.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Structured redacting logger — PLAN_RELAY_AGENT T2 (INV9: secrets never logged, no console.log).
|
||||||
|
*
|
||||||
|
* Meta keys whose name matches a secret substring are replaced with `[REDACTED]` before the
|
||||||
|
* line is emitted, so a caller that accidentally passes `{ privateKey, cert, pairingCode,
|
||||||
|
* agentToken }` can never leak the value. Emission goes through an injectable sink (default
|
||||||
|
* stderr) — `console.log` is never used.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||||
|
|
||||||
|
export interface Logger {
|
||||||
|
log(level: LogLevel, msg: string, meta?: Record<string, unknown>): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sink for a formatted line. Default writes to stderr (NOT console.log). */
|
||||||
|
export type LogSink = (line: string) => void
|
||||||
|
|
||||||
|
const LEVEL_ORDER: Readonly<Record<LogLevel, number>> = {
|
||||||
|
debug: 10,
|
||||||
|
info: 20,
|
||||||
|
warn: 30,
|
||||||
|
error: 40,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Substrings that mark a meta key as secret (case-insensitive). Matches `privateKey` (key),
|
||||||
|
* `cert`, `caChain` (chain? no — cert covers it via 'cert'), `pairingCode` (code), `agentToken`
|
||||||
|
* (token), etc. `nonce`/`streamId`/`hostId` intentionally do NOT match (they are not secrets).
|
||||||
|
*/
|
||||||
|
const REDACT_SUBSTRINGS: readonly string[] = [
|
||||||
|
'key',
|
||||||
|
'cert',
|
||||||
|
'code',
|
||||||
|
'token',
|
||||||
|
'secret',
|
||||||
|
'proof',
|
||||||
|
'password',
|
||||||
|
'pem',
|
||||||
|
]
|
||||||
|
|
||||||
|
const REDACTED = '[REDACTED]'
|
||||||
|
|
||||||
|
function isSecretKey(name: string): boolean {
|
||||||
|
const lower = name.toLowerCase()
|
||||||
|
return REDACT_SUBSTRINGS.some((s) => lower.includes(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactMeta(meta: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {}
|
||||||
|
for (const [k, v] of Object.entries(meta)) {
|
||||||
|
out[k] = isSecretKey(k) ? REDACTED : v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultSink: LogSink = (line) => {
|
||||||
|
process.stderr.write(`${line}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a redacting logger at a minimum level. Lines below `level` are dropped. Secret meta
|
||||||
|
* values are redacted by key name before serialization (INV9).
|
||||||
|
*/
|
||||||
|
export function createLogger(level: LogLevel, sink: LogSink = defaultSink): Logger {
|
||||||
|
const threshold = LEVEL_ORDER[level]
|
||||||
|
return {
|
||||||
|
log(msgLevel, msg, meta) {
|
||||||
|
if (LEVEL_ORDER[msgLevel] < threshold) return
|
||||||
|
const safeMeta = meta ? redactMeta(meta) : undefined
|
||||||
|
const metaPart = safeMeta ? ` ${JSON.stringify(safeMeta)}` : ''
|
||||||
|
sink(`${msgLevel.toUpperCase()} ${msg}${metaPart}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
78
agent/src/service/install.ts
Normal file
78
agent/src/service/install.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Service install dispatcher — PLAN_RELAY_AGENT T17. Detects the platform, writes the unit, and
|
||||||
|
* loads it. REFUSES to install as root (EXPLORE §4d least privilege). All IO is injected so the
|
||||||
|
* logic is unit-testable without touching the real system.
|
||||||
|
*/
|
||||||
|
import type { AgentConfig } from '../config/agentConfig.js'
|
||||||
|
import {
|
||||||
|
buildLaunchdPlist,
|
||||||
|
launchdLoadCommand,
|
||||||
|
launchdPlistPath,
|
||||||
|
launchdUnloadCommand,
|
||||||
|
} from './launchd.js'
|
||||||
|
import {
|
||||||
|
buildSystemdUnit,
|
||||||
|
systemdDisableCommand,
|
||||||
|
systemdEnableCommand,
|
||||||
|
systemdUnitPath,
|
||||||
|
} from './systemd.js'
|
||||||
|
|
||||||
|
export type ServicePlatform = 'launchd' | 'systemd'
|
||||||
|
|
||||||
|
export class RootRefusedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('refusing to install the agent service as root — run as the logged-in user (least privilege)')
|
||||||
|
this.name = 'RootRefusedError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstallDeps {
|
||||||
|
writeFile(path: string, content: string): void
|
||||||
|
runCommand(cmd: string, args: readonly string[]): Promise<void>
|
||||||
|
getuid(): number
|
||||||
|
homedir(): string
|
||||||
|
username(): string
|
||||||
|
binPath(): string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a Node platform to its service manager, or null if unsupported. */
|
||||||
|
export function detectPlatform(os: NodeJS.Platform): ServicePlatform | null {
|
||||||
|
if (os === 'darwin') return 'launchd'
|
||||||
|
if (os === 'linux') return 'systemd'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write + load the service unit for `platform`. Throws RootRefusedError if running as root. */
|
||||||
|
export async function installService(
|
||||||
|
_cfg: AgentConfig,
|
||||||
|
platform: ServicePlatform,
|
||||||
|
deps: InstallDeps,
|
||||||
|
): Promise<void> {
|
||||||
|
if (deps.getuid() === 0) throw new RootRefusedError()
|
||||||
|
const bin = deps.binPath()
|
||||||
|
if (platform === 'launchd') {
|
||||||
|
const path = launchdPlistPath(deps.homedir())
|
||||||
|
deps.writeFile(path, buildLaunchdPlist(bin))
|
||||||
|
const { cmd, args } = launchdLoadCommand(path)
|
||||||
|
await deps.runCommand(cmd, args)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const path = systemdUnitPath(deps.homedir())
|
||||||
|
deps.writeFile(path, buildSystemdUnit(bin, deps.username()))
|
||||||
|
const { cmd, args } = systemdEnableCommand()
|
||||||
|
await deps.runCommand(cmd, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unload the service unit for `platform`. */
|
||||||
|
export async function uninstallService(
|
||||||
|
platform: ServicePlatform,
|
||||||
|
deps: Pick<InstallDeps, 'runCommand' | 'homedir'>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (platform === 'launchd') {
|
||||||
|
const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir()))
|
||||||
|
await deps.runCommand(cmd, args)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { cmd, args } = systemdDisableCommand()
|
||||||
|
await deps.runCommand(cmd, args)
|
||||||
|
}
|
||||||
44
agent/src/service/launchd.ts
Normal file
44
agent/src/service/launchd.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* macOS launchd plist writer — PLAN_RELAY_AGENT T17. The service runs as the LOGGED-IN USER, not
|
||||||
|
* root (EXPLORE §4d least privilege); no secrets in the plist (key/cert stay in the keystore).
|
||||||
|
*/
|
||||||
|
const LABEL = 'com.web-terminal.agent'
|
||||||
|
|
||||||
|
export function launchdLabel(): string {
|
||||||
|
return LABEL
|
||||||
|
}
|
||||||
|
|
||||||
|
export function launchdPlistPath(homedir: string): string {
|
||||||
|
return `${homedir}/Library/LaunchAgents/${LABEL}.plist`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the plist. ExecStart = `<bin> run`; RunAtLoad + KeepAlive (restart on failure). */
|
||||||
|
export function buildLaunchdPlist(binPath: string): string {
|
||||||
|
return [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
||||||
|
'<plist version="1.0">',
|
||||||
|
'<dict>',
|
||||||
|
' <key>Label</key>',
|
||||||
|
` <string>${LABEL}</string>`,
|
||||||
|
' <key>ProgramArguments</key>',
|
||||||
|
' <array>',
|
||||||
|
` <string>${binPath}</string>`,
|
||||||
|
' <string>run</string>',
|
||||||
|
' </array>',
|
||||||
|
' <key>RunAtLoad</key>',
|
||||||
|
' <true/>',
|
||||||
|
' <key>KeepAlive</key>',
|
||||||
|
' <true/>',
|
||||||
|
'</dict>',
|
||||||
|
'</plist>',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function launchdLoadCommand(plistPath: string): { cmd: string; args: readonly string[] } {
|
||||||
|
return { cmd: 'launchctl', args: ['load', plistPath] }
|
||||||
|
}
|
||||||
|
export function launchdUnloadCommand(plistPath: string): { cmd: string; args: readonly string[] } {
|
||||||
|
return { cmd: 'launchctl', args: ['unload', plistPath] }
|
||||||
|
}
|
||||||
57
agent/src/service/originConfig.ts
Normal file
57
agent/src/service/originConfig.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* The ONE base-app touch-point — PLAN_RELAY_AGENT T17 (INDEX §0, EXPLORE §3 "Zero code change").
|
||||||
|
* APPENDS `https://<subdomain>.term.<domain>` to the base app's ALLOWED_ORIGINS env (idempotent),
|
||||||
|
* as CONFIG — NO `src/` code edit. AUGMENTS, never weakens, the Origin/CSWSH check: existing
|
||||||
|
* origins are always preserved (EXPLORE §3 "do not weaken the check").
|
||||||
|
*/
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
export interface OriginFsDeps {
|
||||||
|
exists(path: string): boolean
|
||||||
|
read(path: string): string
|
||||||
|
write(path: string, content: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultFs: OriginFsDeps = {
|
||||||
|
exists: existsSync,
|
||||||
|
read: (p) => readFileSync(p, 'utf8'),
|
||||||
|
write: (p, c) => writeFileSync(p, c),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compose the subdomain origin the base app must trust. */
|
||||||
|
export function subdomainOrigin(subdomain: string, domain: string): string {
|
||||||
|
return `https://${subdomain}.term.${domain}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY = 'ALLOWED_ORIGINS'
|
||||||
|
|
||||||
|
function upsertOriginLine(content: string, origin: string): string {
|
||||||
|
const lines = content.length === 0 ? [] : content.split('\n')
|
||||||
|
let found = false
|
||||||
|
const next = lines.map((line) => {
|
||||||
|
if (!line.startsWith(`${KEY}=`)) return line
|
||||||
|
found = true
|
||||||
|
const current = line.slice(KEY.length + 1)
|
||||||
|
const origins = current.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
|
||||||
|
if (origins.includes(origin)) return line // idempotent — already trusted
|
||||||
|
return `${KEY}=${[...origins, origin].join(',')}`
|
||||||
|
})
|
||||||
|
if (!found) next.push(`${KEY}=${origin}`)
|
||||||
|
return next.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotently append the subdomain origin to ALLOWED_ORIGINS in `baseAppEnvPath`. Never removes an
|
||||||
|
* existing origin. Creates the file/line if absent.
|
||||||
|
*/
|
||||||
|
export function ensureAllowedOrigin(
|
||||||
|
baseAppEnvPath: string,
|
||||||
|
subdomain: string,
|
||||||
|
domain: string,
|
||||||
|
fs: OriginFsDeps = defaultFs,
|
||||||
|
): void {
|
||||||
|
const origin = subdomainOrigin(subdomain, domain)
|
||||||
|
const existing = fs.exists(baseAppEnvPath) ? fs.read(baseAppEnvPath) : ''
|
||||||
|
const updated = upsertOriginLine(existing, origin)
|
||||||
|
fs.write(baseAppEnvPath, updated.endsWith('\n') ? updated : `${updated}\n`)
|
||||||
|
}
|
||||||
40
agent/src/service/systemd.ts
Normal file
40
agent/src/service/systemd.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Linux systemd unit writer — PLAN_RELAY_AGENT T17. Runs as the LOGGED-IN USER (never root,
|
||||||
|
* EXPLORE §4d), restart-on-failure; no secrets in the unit (key/cert stay in the keystore).
|
||||||
|
*/
|
||||||
|
const UNIT_NAME = 'web-terminal-agent.service'
|
||||||
|
|
||||||
|
export function systemdUnitName(): string {
|
||||||
|
return UNIT_NAME
|
||||||
|
}
|
||||||
|
|
||||||
|
export function systemdUnitPath(homedir: string): string {
|
||||||
|
return `${homedir}/.config/systemd/user/${UNIT_NAME}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the unit. ExecStart = `<bin> run`; Restart=on-failure; User=<user> (never root). */
|
||||||
|
export function buildSystemdUnit(binPath: string, user: string): string {
|
||||||
|
return [
|
||||||
|
'[Unit]',
|
||||||
|
'Description=web-terminal host agent (rendezvous relay)',
|
||||||
|
'After=network-online.target',
|
||||||
|
'',
|
||||||
|
'[Service]',
|
||||||
|
'Type=simple',
|
||||||
|
`ExecStart=${binPath} run`,
|
||||||
|
'Restart=on-failure',
|
||||||
|
'RestartSec=1',
|
||||||
|
`User=${user}`,
|
||||||
|
'',
|
||||||
|
'[Install]',
|
||||||
|
'WantedBy=default.target',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function systemdEnableCommand(): { cmd: string; args: readonly string[] } {
|
||||||
|
return { cmd: 'systemctl', args: ['--user', 'enable', '--now', UNIT_NAME] }
|
||||||
|
}
|
||||||
|
export function systemdDisableCommand(): { cmd: string; args: readonly string[] } {
|
||||||
|
return { cmd: 'systemctl', args: ['--user', 'disable', '--now', UNIT_NAME] }
|
||||||
|
}
|
||||||
63
agent/src/transport/backoff.ts
Normal file
63
agent/src/transport/backoff.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Reconnection / backoff — PLAN_RELAY_AGENT T10. REUSES the base app's 1/2/4…cap-30s policy
|
||||||
|
* (EXPLORE §3). `reconnectLoop` only CONSUMES an injected `isRevoked()` (the W0 seam) — a revoked
|
||||||
|
* host short-circuits and never reconnects (INV12). No task edge to T14.
|
||||||
|
*/
|
||||||
|
import type { Tunnel } from './tunnel.js'
|
||||||
|
|
||||||
|
export const BACKOFF_BASE_MS = 1_000
|
||||||
|
export const BACKOFF_CAP_MS = 30_000
|
||||||
|
|
||||||
|
export interface BackoffPolicy {
|
||||||
|
nextDelayMs(): number
|
||||||
|
reset(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exponential backoff 1s,2s,4s…capped at 30s, optional [0.5×,1×] jitter. */
|
||||||
|
export function createBackoff(
|
||||||
|
opts: { baseMs?: number; capMs?: number; jitter?: boolean; rng?: () => number } = {},
|
||||||
|
): BackoffPolicy {
|
||||||
|
const baseMs = opts.baseMs ?? BACKOFF_BASE_MS
|
||||||
|
const capMs = opts.capMs ?? BACKOFF_CAP_MS
|
||||||
|
const jitter = opts.jitter ?? false
|
||||||
|
const rng = opts.rng ?? Math.random
|
||||||
|
let attempt = 0
|
||||||
|
return {
|
||||||
|
nextDelayMs(): number {
|
||||||
|
const raw = Math.min(baseMs * 2 ** attempt, capMs)
|
||||||
|
attempt += 1
|
||||||
|
if (!jitter) return raw
|
||||||
|
return Math.round(raw * (0.5 + rng() * 0.5)) // [0.5×, 1×]
|
||||||
|
},
|
||||||
|
reset(): void {
|
||||||
|
attempt = 0
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Sleep = (ms: number) => Promise<void>
|
||||||
|
const realSleep: Sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dial with backoff until success (resolves the connected Tunnel) or the host is revoked
|
||||||
|
* (resolves null — never reconnect). Each dial failure waits `backoff.nextDelayMs()`; a success
|
||||||
|
* resets the backoff.
|
||||||
|
*/
|
||||||
|
export async function reconnectLoop(
|
||||||
|
dial: () => Promise<Tunnel>,
|
||||||
|
backoff: BackoffPolicy,
|
||||||
|
isRevoked: () => boolean,
|
||||||
|
sleep: Sleep = realSleep,
|
||||||
|
): Promise<Tunnel | null> {
|
||||||
|
while (!isRevoked()) {
|
||||||
|
try {
|
||||||
|
const tunnel = await dial()
|
||||||
|
backoff.reset()
|
||||||
|
return tunnel
|
||||||
|
} catch {
|
||||||
|
if (isRevoked()) return null
|
||||||
|
await sleep(backoff.nextDelayMs())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
103
agent/src/transport/dial.ts
Normal file
103
agent/src/transport/dial.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Outbound mTLS dial — PLAN_RELAY_AGENT T12 (INV14/INV4). Builds a wss:// client authenticated by
|
||||||
|
* the client cert + IN-PROCESS Ed25519 key + pinned CA chain, `rejectUnauthorized: true`, and NO
|
||||||
|
* bearer/agent token (mTLS IS the auth). Absent/expired cert ⇒ fail-fast, no dial.
|
||||||
|
*/
|
||||||
|
import { X509Certificate } from 'node:crypto'
|
||||||
|
import type { Keystore } from '../keys/keystore.js'
|
||||||
|
import type { AgentConfig } from '../config/agentConfig.js'
|
||||||
|
import type { WsLike } from './seams.js'
|
||||||
|
|
||||||
|
export class NotEnrolledError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('agent is not enrolled (no identity/cert in keystore)')
|
||||||
|
this.name = 'NotEnrolledError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class CertExpiredError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('client certificate has expired; renew before dialling')
|
||||||
|
this.name = 'CertExpiredError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TLS material for the wss client. NOTE: `rejectUnauthorized` is ALWAYS true (anti-MITM). */
|
||||||
|
export interface TlsClientOptions {
|
||||||
|
readonly cert: string
|
||||||
|
readonly key: string
|
||||||
|
readonly ca: string
|
||||||
|
readonly rejectUnauthorized: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CertInfo {
|
||||||
|
readonly validTo: Date
|
||||||
|
}
|
||||||
|
export type CertParser = (certPem: string) => CertInfo
|
||||||
|
const defaultCertParser: CertParser = (pem) => ({ validTo: new Date(new X509Certificate(pem).validTo) })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble the mTLS options from the keystore. Throws NotEnrolledError if key/cert are missing,
|
||||||
|
* CertExpiredError if the cert is past `validTo`. There is NO token field by construction (INV4).
|
||||||
|
*/
|
||||||
|
export function buildTlsOptions(
|
||||||
|
ks: Keystore,
|
||||||
|
opts: { now?: Date; certParser?: CertParser } = {},
|
||||||
|
): TlsClientOptions {
|
||||||
|
const id = ks.loadIdentity()
|
||||||
|
const certs = ks.loadCert()
|
||||||
|
if (id === null || certs === null) throw new NotEnrolledError()
|
||||||
|
const parse = opts.certParser ?? defaultCertParser
|
||||||
|
const now = opts.now ?? new Date()
|
||||||
|
if (parse(certs.certPem).validTo.getTime() < now.getTime()) throw new CertExpiredError()
|
||||||
|
return {
|
||||||
|
cert: certs.certPem,
|
||||||
|
key: id.exportPrivatePkcs8Pem(),
|
||||||
|
ca: certs.caChainPem,
|
||||||
|
rejectUnauthorized: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawTlsWs {
|
||||||
|
send(data: Uint8Array): void
|
||||||
|
close(): void
|
||||||
|
on(event: string, cb: (...args: unknown[]) => void): void
|
||||||
|
once(event: string, cb: (...args: unknown[]) => void): void
|
||||||
|
}
|
||||||
|
export type TlsWsConstructor = new (url: string, opts: TlsClientOptions) => RawTlsWs
|
||||||
|
|
||||||
|
function toU8(data: unknown): Uint8Array | null {
|
||||||
|
if (data instanceof Uint8Array) return data
|
||||||
|
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function adapt(raw: RawTlsWs): WsLike {
|
||||||
|
return {
|
||||||
|
send: (d) => raw.send(d),
|
||||||
|
on(ev, cb) {
|
||||||
|
if (ev === 'message') {
|
||||||
|
raw.on('message', (data: unknown) => {
|
||||||
|
const bytes = toU8(data)
|
||||||
|
if (bytes !== null) cb(bytes)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
raw.on(ev, cb)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close: () => raw.close(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dial the relay's /agent endpoint over mTLS wss. Resolves the connected WsLike on open. */
|
||||||
|
export function dialRelay(
|
||||||
|
cfg: AgentConfig,
|
||||||
|
ks: Keystore,
|
||||||
|
opts: { Ctor: TlsWsConstructor; now?: Date; certParser?: CertParser },
|
||||||
|
): Promise<WsLike> {
|
||||||
|
const tls = buildTlsOptions(ks, { ...(opts.now ? { now: opts.now } : {}), ...(opts.certParser ? { certParser: opts.certParser } : {}) })
|
||||||
|
return new Promise<WsLike>((resolve, reject) => {
|
||||||
|
const raw = new opts.Ctor(cfg.relayUrl, tls)
|
||||||
|
raw.once('open', () => resolve(adapt(raw)))
|
||||||
|
raw.once('error', (err: unknown) => reject(err instanceof Error ? err : new Error(String(err))))
|
||||||
|
})
|
||||||
|
}
|
||||||
41
agent/src/transport/flowControl.ts
Normal file
41
agent/src/transport/flowControl.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Per-stream flow control — PLAN_RELAY_AGENT T11. CONSUMES the §4.1 WINDOW_UPDATE credit protocol
|
||||||
|
* (P1 owns the protocol). Per-stream credit means one heavy vim/top redraw can't starve another
|
||||||
|
* stream. streamId 0 is the connection-level window applied to the whole link.
|
||||||
|
*/
|
||||||
|
export interface FlowController {
|
||||||
|
consume(streamId: number, bytes: number): boolean
|
||||||
|
grant(streamId: number, credit: number): void
|
||||||
|
initWindow(streamId: number, initialCredit: number): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONNECTION_STREAM_ID = 0
|
||||||
|
|
||||||
|
export function createFlowController(): FlowController {
|
||||||
|
const windows = new Map<number, number>()
|
||||||
|
// Connection-level window is unbounded until explicitly initialized.
|
||||||
|
windows.set(CONNECTION_STREAM_ID, Number.POSITIVE_INFINITY)
|
||||||
|
|
||||||
|
function remaining(streamId: number): number {
|
||||||
|
return windows.get(streamId) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
initWindow(streamId: number, initialCredit: number): void {
|
||||||
|
windows.set(streamId, initialCredit)
|
||||||
|
},
|
||||||
|
grant(streamId: number, credit: number): void {
|
||||||
|
windows.set(streamId, remaining(streamId) + credit)
|
||||||
|
},
|
||||||
|
consume(streamId: number, bytes: number): boolean {
|
||||||
|
const conn = remaining(CONNECTION_STREAM_ID)
|
||||||
|
const stream = remaining(streamId)
|
||||||
|
if (stream < bytes || conn < bytes) return false // credit exhausted → pause
|
||||||
|
windows.set(streamId, stream - bytes)
|
||||||
|
if (conn !== Number.POSITIVE_INFINITY) {
|
||||||
|
windows.set(CONNECTION_STREAM_ID, conn - bytes)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
72
agent/src/transport/frpScaffold.ts
Normal file
72
agent/src/transport/frpScaffold.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* v0.8 frpc-wrap stepping-stone — PLAN_RELAY_AGENT T6. Fastest path to the café demo: wraps a
|
||||||
|
* child `frpc` presenting the shared v0.8 `agentToken`, registering the subdomain, forwarding to
|
||||||
|
* 127.0.0.1:3000. EXPLICITLY a stepping-stone — the native mux (T7–T11) replaces it at v0.9.
|
||||||
|
*
|
||||||
|
* Forwards ONLY to loopback (anti-SSRF). Retired once EnrollMode==='ed25519' (guard below).
|
||||||
|
*/
|
||||||
|
import type { AgentConfig } from '../config/agentConfig.js'
|
||||||
|
import { isLoopbackWsUrl } from '../config/agentConfig.js'
|
||||||
|
import type { EnrollMode } from '../enroll/pair.js'
|
||||||
|
|
||||||
|
export interface FrpScaffold {
|
||||||
|
start(): Promise<void>
|
||||||
|
stop(): Promise<void>
|
||||||
|
onExit(cb: (code: number) => void): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal child-process seam so tests inject a fake spawn (no real frpc needed). */
|
||||||
|
export interface ChildLike {
|
||||||
|
on(ev: 'exit', cb: (code: number | null) => void): void
|
||||||
|
kill(): void
|
||||||
|
}
|
||||||
|
export type SpawnImpl = (cmd: string, args: readonly string[]) => ChildLike
|
||||||
|
|
||||||
|
const LOOPBACK_IP = '127.0.0.1'
|
||||||
|
const LOCAL_PORT = 3000
|
||||||
|
|
||||||
|
/** Build the frpc.toml. local_ip is ALWAYS loopback; tls is enabled. */
|
||||||
|
export function buildFrpcToml(cfg: AgentConfig): string {
|
||||||
|
if (!isLoopbackWsUrl(cfg.localTargetUrl)) {
|
||||||
|
throw new Error('frpScaffold refuses a non-loopback localTargetUrl (anti-SSRF)')
|
||||||
|
}
|
||||||
|
const subdomain = cfg.subdomain ?? ''
|
||||||
|
return [
|
||||||
|
'[common]',
|
||||||
|
'tls_enable = true',
|
||||||
|
'',
|
||||||
|
'[web-terminal]',
|
||||||
|
'type = "tcp"',
|
||||||
|
`local_ip = "${LOOPBACK_IP}"`,
|
||||||
|
`local_port = ${LOCAL_PORT}`,
|
||||||
|
`subdomain = "${subdomain}"`,
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once the native Ed25519 substrate is active — `run` must NOT wire frpc then. */
|
||||||
|
export function isFrpRetired(mode: EnrollMode): boolean {
|
||||||
|
return mode === 'ed25519'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spawn (a mockable) frpc child with a generated config. */
|
||||||
|
export function spawnFrpc(cfg: AgentConfig, frpcPath: string, spawnImpl: SpawnImpl): FrpScaffold {
|
||||||
|
const toml = buildFrpcToml(cfg) // validates loopback before spawning
|
||||||
|
void toml
|
||||||
|
let child: ChildLike | null = null
|
||||||
|
const exitCbs: Array<(code: number) => void> = []
|
||||||
|
return {
|
||||||
|
async start(): Promise<void> {
|
||||||
|
child = spawnImpl(frpcPath, ['-c', 'frpc.toml'])
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
for (const cb of exitCbs) cb(code ?? 0)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
child?.kill()
|
||||||
|
},
|
||||||
|
onExit(cb: (code: number) => void): void {
|
||||||
|
exitCbs.push(cb)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
84
agent/src/transport/heartbeat.ts
Normal file
84
agent/src/transport/heartbeat.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* §4.1 heartbeat — PLAN_RELAY_AGENT T9. PING every 15s on streamId 0; a missed PONG within the
|
||||||
|
* interval ⇒ the tunnel is dead (⇒ T10 reconnect). Also replies PONG (echoing the 8-byte token)
|
||||||
|
* to an inbound PING. Timers are injectable (TimerLike) for deterministic fake-timer tests.
|
||||||
|
*/
|
||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
import type { TimerLike } from './seams.js'
|
||||||
|
import { pingHeader, pongHeader, type Tunnel } from './tunnel.js'
|
||||||
|
|
||||||
|
export const HEARTBEAT_INTERVAL_MS = 15_000
|
||||||
|
|
||||||
|
export interface Heartbeat {
|
||||||
|
onPing(token: Uint8Array): void
|
||||||
|
onPong(token: Uint8Array): void
|
||||||
|
start(): void
|
||||||
|
stop(): void
|
||||||
|
onDead(cb: () => void): void
|
||||||
|
}
|
||||||
|
|
||||||
|
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>),
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHeartbeat(
|
||||||
|
tunnel: Tunnel,
|
||||||
|
opts: { intervalMs?: number; timer?: TimerLike; genToken?: () => Uint8Array } = {},
|
||||||
|
): Heartbeat {
|
||||||
|
const intervalMs = opts.intervalMs ?? HEARTBEAT_INTERVAL_MS
|
||||||
|
const timer = opts.timer ?? realTimer
|
||||||
|
const genToken = opts.genToken ?? (() => new Uint8Array(randomBytes(8)))
|
||||||
|
|
||||||
|
let interval: unknown = null
|
||||||
|
let deadline: unknown = null
|
||||||
|
let pending = false
|
||||||
|
let deadCb: (() => void) | null = null
|
||||||
|
let dead = false
|
||||||
|
|
||||||
|
function fireDead(): void {
|
||||||
|
if (dead) return
|
||||||
|
dead = true
|
||||||
|
stop()
|
||||||
|
deadCb?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendPing(): void {
|
||||||
|
pending = true
|
||||||
|
tunnel.send(pingHeader(), genToken())
|
||||||
|
deadline = timer.setTimeout(() => {
|
||||||
|
if (pending) fireDead()
|
||||||
|
}, intervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop(): void {
|
||||||
|
if (interval !== null) timer.clearInterval(interval)
|
||||||
|
if (deadline !== null) timer.clearTimeout(deadline)
|
||||||
|
interval = null
|
||||||
|
deadline = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
onPing(token: Uint8Array): void {
|
||||||
|
tunnel.send(pongHeader(), token) // echo the token byte-exact
|
||||||
|
},
|
||||||
|
onPong(): void {
|
||||||
|
pending = false
|
||||||
|
if (deadline !== null) {
|
||||||
|
timer.clearTimeout(deadline)
|
||||||
|
deadline = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
start(): void {
|
||||||
|
dead = false
|
||||||
|
sendPing()
|
||||||
|
interval = timer.setInterval(sendPing, intervalMs)
|
||||||
|
},
|
||||||
|
stop,
|
||||||
|
onDead(cb: () => void): void {
|
||||||
|
deadCb = cb
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
71
agent/src/transport/loopback.ts
Normal file
71
agent/src/transport/loopback.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Loopback forwarder — PLAN_RELAY_AGENT T8. One OPEN ⇒ one fresh ws://127.0.0.1:3000<path>
|
||||||
|
* socket, REPLAYING the real browser `Origin` (§4.1 MuxOpen.originHeader) so the UNCHANGED base
|
||||||
|
* app's Origin check still passes end-to-end (CSWSH protection preserved — EXPLORE §3).
|
||||||
|
*
|
||||||
|
* The raw `ws` constructor is injectable so the URL/Origin wiring is unit-testable without a real
|
||||||
|
* socket. The target is always loopback (validated upstream in config).
|
||||||
|
*/
|
||||||
|
import type { WsLike } from './seams.js'
|
||||||
|
|
||||||
|
export type DialLoopback = (path: string, origin: string) => Promise<WsLike>
|
||||||
|
|
||||||
|
/** Minimal surface of a raw `ws` client the adapter needs. */
|
||||||
|
export interface RawWs {
|
||||||
|
send(data: Uint8Array): void
|
||||||
|
close(): void
|
||||||
|
on(event: string, cb: (...args: unknown[]) => void): void
|
||||||
|
once(event: string, cb: (...args: unknown[]) => void): void
|
||||||
|
}
|
||||||
|
export type WsConstructor = new (
|
||||||
|
url: string,
|
||||||
|
opts?: { headers?: Record<string, string> },
|
||||||
|
) => RawWs
|
||||||
|
|
||||||
|
/** Join the loopback target with the request path (avoids a double slash). */
|
||||||
|
export function buildLoopbackUrl(target: string, path: string): string {
|
||||||
|
const base = target.endsWith('/') ? target.slice(0, -1) : target
|
||||||
|
const suffix = path.startsWith('/') ? path : `/${path}`
|
||||||
|
return `${base}${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function toU8(data: unknown): Uint8Array | null {
|
||||||
|
if (data instanceof Uint8Array) return data
|
||||||
|
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function adapt(raw: RawWs): WsLike {
|
||||||
|
return {
|
||||||
|
send(d: Uint8Array): void {
|
||||||
|
raw.send(d)
|
||||||
|
},
|
||||||
|
on(ev: 'message' | 'close' | 'error', cb: (...a: unknown[]) => void): void {
|
||||||
|
if (ev === 'message') {
|
||||||
|
raw.on('message', (data: unknown) => {
|
||||||
|
const bytes = toU8(data)
|
||||||
|
if (bytes !== null) cb(bytes)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
raw.on(ev, cb)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close(): void {
|
||||||
|
raw.close()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a DialLoopback bound to `target`. Resolves once the loopback socket is open; rejects on
|
||||||
|
* a pre-open error (so a down base app surfaces as a typed dial failure, not a silent hang).
|
||||||
|
*/
|
||||||
|
export function dialLoopback(target: string, Ctor: WsConstructor): DialLoopback {
|
||||||
|
return (path: string, origin: string): Promise<WsLike> =>
|
||||||
|
new Promise<WsLike>((resolve, reject) => {
|
||||||
|
const url = buildLoopbackUrl(target, path)
|
||||||
|
const raw = new Ctor(url, { headers: { Origin: origin } })
|
||||||
|
raw.once('open', () => resolve(adapt(raw)))
|
||||||
|
raw.once('error', (err: unknown) => reject(err instanceof Error ? err : new Error(String(err))))
|
||||||
|
})
|
||||||
|
}
|
||||||
43
agent/src/transport/seams.ts
Normal file
43
agent/src/transport/seams.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* W0 shared injection seams (DEPENDENCY-CYCLE BREAKER) — PLAN_RELAY_AGENT §2 / T2.
|
||||||
|
*
|
||||||
|
* These are intra-`agent/` seam *types only*. NO cross-plan frozen contract lives here —
|
||||||
|
* those stay in `relay-contracts/`. Declaring them once at W0 lets W2/W3 transport tasks
|
||||||
|
* (T7/T10/T12/T14) consume a stable type WITHOUT a task-level cycle (see §3 T10↔T14 note).
|
||||||
|
*
|
||||||
|
* This module MUST remain side-effect-free and runtime-dependency-free (type-only): a test
|
||||||
|
* asserts importing it pulls in no `ws`/crypto runtime, so W2/W3 can depend on it freely.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal WS surface the transport layer needs. dial/tunnel/backoff/loopback share it so no
|
||||||
|
* per-task redeclare and no drift. Adapters wrap the real `ws` socket into this shape.
|
||||||
|
*/
|
||||||
|
export interface WsLike {
|
||||||
|
send(d: Uint8Array): void
|
||||||
|
on(ev: 'message' | 'close' | 'error', cb: (...a: unknown[]) => void): void
|
||||||
|
close(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why a host stopped tunnelling. `renewal-refused`/`goaway-revoked` ⇒ do NOT reconnect. */
|
||||||
|
export type RevokeReason = 'renewal-refused' | 'goaway-revoked' | 'operator'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revocation seam — T14 IMPLEMENTS it; T10's reconnectLoop only CONSUMES `isRevoked()`.
|
||||||
|
* One-way edge (T14 → wires T10's loop), so there is no task cycle.
|
||||||
|
*/
|
||||||
|
export interface RevocationState {
|
||||||
|
isRevoked(): boolean
|
||||||
|
markRevoked(reason: RevokeReason): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal timer seam so heartbeat (T9) / cert rotation (T13) are testable with fake timers
|
||||||
|
* without depending on Node's global timer types leaking into the transport surface.
|
||||||
|
*/
|
||||||
|
export interface TimerLike {
|
||||||
|
setTimeout(cb: () => void, ms: number): unknown
|
||||||
|
clearTimeout(handle: unknown): void
|
||||||
|
setInterval(cb: () => void, ms: number): unknown
|
||||||
|
clearInterval(handle: unknown): void
|
||||||
|
}
|
||||||
148
agent/src/transport/streamRouter.ts
Normal file
148
agent/src/transport/streamRouter.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* Stream router — PLAN_RELAY_AGENT T8. Maps §4.1 streamId ⇄ a loopback socket. Each OPEN gets a
|
||||||
|
* FRESH per-stream allocation (socket + transform state); there are NO global mutable buffers, so
|
||||||
|
* cross-tenant/cross-stream buffer bleed is structurally impossible (EXPLORE §4b failure #3).
|
||||||
|
*
|
||||||
|
* MANDATORY INV1 defense-in-depth: the FIRST thing handleOpen does — before any allocation or
|
||||||
|
* dial — is compare MuxOpen.subdomain (§4.1) against this agent's enrolled subdomain. A mismatch
|
||||||
|
* is RST and NEVER dialed (metadata-only audit log, INV10). Belt-and-suspenders to relay authz.
|
||||||
|
*/
|
||||||
|
import type { MuxOpen } from 'relay-contracts'
|
||||||
|
import { MuxOpenSchema } from 'relay-contracts'
|
||||||
|
import type { AgentConfig } from '../config/agentConfig.js'
|
||||||
|
import type { Logger } from '../log/logger.js'
|
||||||
|
import type { WsLike } from './seams.js'
|
||||||
|
import type { DialLoopback } from './loopback.js'
|
||||||
|
import { closeHeader, dataHeader, type Tunnel } from './tunnel.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-stream cipher transform. Identity in v0.9 (plaintext passthrough); replaced by the E2E
|
||||||
|
* codec in v0.10 (T15). `takeControlFrames` lets an E2E transform emit host→client control frames
|
||||||
|
* (e.g. HostHello) that the router forwards upstream — no-op for the identity transform.
|
||||||
|
*/
|
||||||
|
export interface FrameTransform {
|
||||||
|
inbound(streamId: number, cipher: Uint8Array): Uint8Array | null
|
||||||
|
outbound(streamId: number, plain: Uint8Array): Uint8Array
|
||||||
|
openStream(streamId: number): void
|
||||||
|
closeStream(streamId: number): void
|
||||||
|
takeControlFrames?(streamId: number): Uint8Array[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const identityTransform: FrameTransform = {
|
||||||
|
inbound: (_s, cipher) => cipher,
|
||||||
|
outbound: (_s, plain) => plain,
|
||||||
|
openStream: () => {},
|
||||||
|
closeStream: () => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamRouter {
|
||||||
|
handleOpen(open: MuxOpen): void
|
||||||
|
handleData(streamId: number, payload: Uint8Array): void
|
||||||
|
handleClose(streamId: number, rst: boolean): void
|
||||||
|
activeStreamCount(): number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StreamState {
|
||||||
|
socket: WsLike | null
|
||||||
|
readonly pending: Uint8Array[] // inbound bytes buffered until the loopback socket is open
|
||||||
|
closed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStreamRouter(
|
||||||
|
cfg: AgentConfig,
|
||||||
|
tunnel: Tunnel,
|
||||||
|
dial: DialLoopback,
|
||||||
|
transform: FrameTransform,
|
||||||
|
logger: Logger,
|
||||||
|
): StreamRouter {
|
||||||
|
const streams = new Map<number, StreamState>()
|
||||||
|
|
||||||
|
function flushControlFrames(streamId: number): void {
|
||||||
|
const frames = transform.takeControlFrames?.(streamId) ?? []
|
||||||
|
for (const frame of frames) {
|
||||||
|
tunnel.send(dataHeader(streamId, frame.length), frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function teardown(streamId: number, rst: boolean): void {
|
||||||
|
const state = streams.get(streamId)
|
||||||
|
if (state === undefined) return
|
||||||
|
// Delete FIRST so a synchronous socket 'close' event can't re-enter this teardown.
|
||||||
|
streams.delete(streamId)
|
||||||
|
state.closed = true
|
||||||
|
transform.closeStream(streamId)
|
||||||
|
tunnel.send(closeHeader(streamId, rst), new Uint8Array(0))
|
||||||
|
state.socket?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleOpen(open: MuxOpen): void {
|
||||||
|
// INV1 defense-in-depth — FIRST statement, before any allocation or dial.
|
||||||
|
if (open.subdomain !== cfg.subdomain) {
|
||||||
|
tunnel.sendRst(open.streamId)
|
||||||
|
logger.log('error', 'open.subdomain mismatch — refusing to dial', { streamId: open.streamId })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!MuxOpenSchema.safeParse(open).success) {
|
||||||
|
tunnel.sendRst(open.streamId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (streams.has(open.streamId)) {
|
||||||
|
tunnel.sendRst(open.streamId) // duplicate OPEN for a live stream
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: StreamState = { socket: null, pending: [], closed: false }
|
||||||
|
streams.set(open.streamId, state)
|
||||||
|
transform.openStream(open.streamId)
|
||||||
|
|
||||||
|
dial(open.requestPath, open.originHeader)
|
||||||
|
.then((socket) => {
|
||||||
|
if (state.closed) {
|
||||||
|
socket.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.socket = socket
|
||||||
|
// loopback output → transform.outbound → tunnel DATA
|
||||||
|
socket.on('message', (data: unknown) => {
|
||||||
|
if (!(data instanceof Uint8Array)) return
|
||||||
|
const cipher = transform.outbound(open.streamId, data)
|
||||||
|
tunnel.send(dataHeader(open.streamId, cipher.length), cipher)
|
||||||
|
})
|
||||||
|
socket.on('close', () => teardown(open.streamId, false))
|
||||||
|
// flush anything buffered before the socket opened
|
||||||
|
for (const buffered of state.pending) socket.send(buffered)
|
||||||
|
state.pending.length = 0
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
logger.log('error', 'loopback dial failed', { streamId: open.streamId })
|
||||||
|
void err
|
||||||
|
teardown(open.streamId, true)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleData(streamId: number, payload: Uint8Array): void {
|
||||||
|
const state = streams.get(streamId)
|
||||||
|
if (state === undefined) {
|
||||||
|
tunnel.sendRst(streamId) // DATA before OPEN / after CLOSE / unknown stream → RST
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const plain = transform.inbound(streamId, payload)
|
||||||
|
flushControlFrames(streamId) // E2E: emit HostHello etc. (no-op in v0.9)
|
||||||
|
if (plain === null) return // consumed (handshake), nothing to forward
|
||||||
|
if (state.socket === null) {
|
||||||
|
state.pending.push(plain)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.socket.send(plain)
|
||||||
|
},
|
||||||
|
|
||||||
|
handleClose(streamId: number, rst: boolean): void {
|
||||||
|
teardown(streamId, rst)
|
||||||
|
},
|
||||||
|
|
||||||
|
activeStreamCount(): number {
|
||||||
|
return streams.size
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
161
agent/src/transport/tunnel.ts
Normal file
161
agent/src/transport/tunnel.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* §4.1 tunnel holder — PLAN_RELAY_AGENT T7. Holds ONE physical mux over a WsLike socket:
|
||||||
|
* encodes outbound frames, decodes inbound frames (via the FROZEN relay-contracts codec — never
|
||||||
|
* re-implemented), and dispatches by type to the router (OPEN/DATA/CLOSE) or heartbeat
|
||||||
|
* (PING/PONG) or connection-level control (GOAWAY/WINDOW_UPDATE, streamId 0).
|
||||||
|
*
|
||||||
|
* INV11: payloads are OPAQUE — no ANSI/terminal parsing here. Malformed frames RST the affected
|
||||||
|
* stream (never the whole tunnel). After an inbound GOAWAY the tunnel DRAINS: no new OPEN.
|
||||||
|
*
|
||||||
|
* Design note (vs plan `holdTunnel(socket, router, heartbeat)`): to keep the construction graph
|
||||||
|
* ACYCLIC (router/heartbeat are built WITH the tunnel), wiring is a two-phase `dispatchTo()`
|
||||||
|
* call rather than constructor args. Same behavior, no task cycle. Recorded as a deviation.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
decodeGoaway,
|
||||||
|
decodeMuxFrame,
|
||||||
|
decodeOpen,
|
||||||
|
encodeGoaway,
|
||||||
|
encodeMuxFrame,
|
||||||
|
} from 'relay-contracts'
|
||||||
|
import type { GoAwayReason, MuxFrameHeader, MuxOpen } from 'relay-contracts'
|
||||||
|
import type { WsLike } from './seams.js'
|
||||||
|
|
||||||
|
const EMPTY = new Uint8Array(0)
|
||||||
|
|
||||||
|
/** Handlers the tunnel dispatches decoded frames to (wired post-construction). */
|
||||||
|
export interface StreamHandlers {
|
||||||
|
handleOpen(open: MuxOpen): void
|
||||||
|
handleData(streamId: number, payload: Uint8Array): void
|
||||||
|
handleClose(streamId: number, rst: boolean): void
|
||||||
|
}
|
||||||
|
export interface HeartbeatSink {
|
||||||
|
onPing(token: Uint8Array): void
|
||||||
|
onPong(token: Uint8Array): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tunnel {
|
||||||
|
send(header: MuxFrameHeader, payload: Uint8Array): void
|
||||||
|
onFrame(cb: (h: MuxFrameHeader, payload: Uint8Array) => void): void
|
||||||
|
onGoAway(cb: (reason: GoAwayReason) => void): void
|
||||||
|
goAway(lastStreamId: number, reason: GoAwayReason): void
|
||||||
|
sendRst(streamId: number): void
|
||||||
|
dispatchTo(router: StreamHandlers, heartbeat: HeartbeatSink): void
|
||||||
|
close(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- frame-header builders (shared across the transport layer) --------------------------------
|
||||||
|
|
||||||
|
export function dataHeader(streamId: number, payloadLen: number): MuxFrameHeader {
|
||||||
|
return { version: 1, type: 'data', fin: false, rst: false, streamId, payloadLen }
|
||||||
|
}
|
||||||
|
export function closeHeader(streamId: number, rst: boolean): MuxFrameHeader {
|
||||||
|
return { version: 1, type: 'close', fin: !rst, rst, streamId, payloadLen: 0 }
|
||||||
|
}
|
||||||
|
export function rstHeader(streamId: number): MuxFrameHeader {
|
||||||
|
return closeHeader(streamId, true)
|
||||||
|
}
|
||||||
|
export function pingHeader(): MuxFrameHeader {
|
||||||
|
return { version: 1, type: 'ping', fin: false, rst: false, streamId: 0, payloadLen: 8 }
|
||||||
|
}
|
||||||
|
export function pongHeader(): MuxFrameHeader {
|
||||||
|
return { version: 1, type: 'pong', fin: false, rst: false, streamId: 0, payloadLen: 8 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toU8(data: unknown): Uint8Array | null {
|
||||||
|
if (data instanceof Uint8Array) return data
|
||||||
|
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||||
|
if (Array.isArray(data) && data[0] instanceof Uint8Array) return data[0] as Uint8Array
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function holdTunnel(socket: WsLike): Tunnel {
|
||||||
|
let frameCb: ((h: MuxFrameHeader, payload: Uint8Array) => void) | null = null
|
||||||
|
let goAwayCb: ((reason: GoAwayReason) => void) | null = null
|
||||||
|
let handlers: StreamHandlers | null = null
|
||||||
|
let heartbeat: HeartbeatSink | null = null
|
||||||
|
let draining = false
|
||||||
|
|
||||||
|
function send(header: MuxFrameHeader, payload: Uint8Array): void {
|
||||||
|
socket.send(encodeMuxFrame(header, payload))
|
||||||
|
}
|
||||||
|
function sendRst(streamId: number): void {
|
||||||
|
send(rstHeader(streamId), EMPTY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatch(header: MuxFrameHeader, payload: Uint8Array): void {
|
||||||
|
frameCb?.(header, payload)
|
||||||
|
switch (header.type) {
|
||||||
|
case 'ping':
|
||||||
|
heartbeat?.onPing(payload)
|
||||||
|
return
|
||||||
|
case 'pong':
|
||||||
|
heartbeat?.onPong(payload)
|
||||||
|
return
|
||||||
|
case 'goaway': {
|
||||||
|
const { reason } = decodeGoaway(payload)
|
||||||
|
draining = true
|
||||||
|
goAwayCb?.(reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'open': {
|
||||||
|
if (draining) {
|
||||||
|
sendRst(header.streamId) // drain: refuse new streams
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let open: MuxOpen
|
||||||
|
try {
|
||||||
|
open = decodeOpen(payload)
|
||||||
|
} catch {
|
||||||
|
sendRst(header.streamId) // malformed OPEN → RST that stream, tunnel stays up
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handlers?.handleOpen(open)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'data':
|
||||||
|
handlers?.handleData(header.streamId, payload)
|
||||||
|
return
|
||||||
|
case 'close':
|
||||||
|
handlers?.handleClose(header.streamId, header.rst)
|
||||||
|
return
|
||||||
|
case 'windowUpdate':
|
||||||
|
return // consumed by the flow controller at the wiring layer (T11)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('message', (...args: unknown[]) => {
|
||||||
|
const bytes = toU8(args[0])
|
||||||
|
if (bytes === null) return
|
||||||
|
let decoded: { header: MuxFrameHeader; payload: Uint8Array }
|
||||||
|
try {
|
||||||
|
decoded = decodeMuxFrame(bytes)
|
||||||
|
} catch {
|
||||||
|
return // malformed framing: drop the frame, keep the tunnel alive (robust framing)
|
||||||
|
}
|
||||||
|
dispatch(decoded.header, decoded.payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
send,
|
||||||
|
sendRst,
|
||||||
|
onFrame(cb): void {
|
||||||
|
frameCb = cb
|
||||||
|
},
|
||||||
|
onGoAway(cb): void {
|
||||||
|
goAwayCb = cb
|
||||||
|
},
|
||||||
|
goAway(lastStreamId: number, reason: GoAwayReason): void {
|
||||||
|
draining = true
|
||||||
|
const payload = encodeGoaway(lastStreamId, reason)
|
||||||
|
send({ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: payload.length }, payload)
|
||||||
|
},
|
||||||
|
dispatchTo(router: StreamHandlers, hb: HeartbeatSink): void {
|
||||||
|
handlers = router
|
||||||
|
heartbeat = hb
|
||||||
|
},
|
||||||
|
close(): void {
|
||||||
|
socket.close()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
64
agent/test/acceptance/cafeDemo.test.ts
Normal file
64
agent/test/acceptance/cafeDemo.test.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { decodeMuxFrame, encodeMuxFrame, encodeOpen, type MuxOpen } from 'relay-contracts'
|
||||||
|
import type { AgentConfig } from '../../src/config/agentConfig.js'
|
||||||
|
import { createLogger } from '../../src/log/logger.js'
|
||||||
|
import { holdTunnel, dataHeader } from '../../src/transport/tunnel.js'
|
||||||
|
import { createStreamRouter, identityTransform } from '../../src/transport/streamRouter.js'
|
||||||
|
import { FakeWs } from '../fixtures/fakes.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T18 café-demo agent slice: pair (implied) → dial (mock) → OPEN → loopback splice against a stub
|
||||||
|
* ws://127.0.0.1:3000 echo server → bytes flow BOTH ways (EXPLORE §5 demo, agent portion).
|
||||||
|
*/
|
||||||
|
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 flush = () => new Promise((r) => setImmediate(r))
|
||||||
|
|
||||||
|
describe('café-demo agent slice (T18)', () => {
|
||||||
|
it('splices bytes both ways through the loopback echo', async () => {
|
||||||
|
const upstream = new FakeWs()
|
||||||
|
const tunnel = holdTunnel(upstream)
|
||||||
|
const loopback = new FakeWs()
|
||||||
|
const dial = vi.fn(async () => loopback)
|
||||||
|
const router = createStreamRouter(CFG, tunnel, dial as never, identityTransform, createLogger('error', () => {}))
|
||||||
|
tunnel.dispatchTo(router, { onPing: () => {}, onPong: () => {} })
|
||||||
|
|
||||||
|
// relay → agent: OPEN + a keystroke
|
||||||
|
ws_emitOpen(upstream, OPEN)
|
||||||
|
await flush()
|
||||||
|
expect(dial).toHaveBeenCalledWith('/term?join=abc', 'https://host-42.term.example.com')
|
||||||
|
|
||||||
|
upstream.emitMessage(encodeMuxFrame(dataHeader(5, 3), new Uint8Array([104, 105, 10]))) // "hi\n"
|
||||||
|
expect(loopback.sent.at(-1)).toEqual(new Uint8Array([104, 105, 10]))
|
||||||
|
|
||||||
|
// agent ← base app: echo output flows back as DATA upstream
|
||||||
|
loopback.emit('message', new Uint8Array([79, 75])) // "OK"
|
||||||
|
const last = decodeMuxFrame(upstream.sent.at(-1)!)
|
||||||
|
expect(last.header.type).toBe('data')
|
||||||
|
expect([...last.payload]).toEqual([79, 75])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function ws_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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
53
agent/test/agentConfig.test.ts
Normal file
53
agent/test/agentConfig.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { AgentConfigSchema, isLoopbackWsUrl, loadAgentConfig } from '../src/config/agentConfig.js'
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
relayUrl: 'wss://relay.example.com/agent',
|
||||||
|
enrollUrl: 'https://example.com/enroll',
|
||||||
|
stateDir: '/tmp/wta',
|
||||||
|
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||||
|
subdomain: null,
|
||||||
|
hostId: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AgentConfig validation', () => {
|
||||||
|
it('parses a valid config', () => {
|
||||||
|
expect(() => AgentConfigSchema.parse(base)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a non-wss relayUrl', () => {
|
||||||
|
expect(() => AgentConfigSchema.parse({ ...base, relayUrl: 'http://relay.example.com' })).toThrow()
|
||||||
|
expect(() => AgentConfigSchema.parse({ ...base, relayUrl: 'ws://relay.example.com' })).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a non-https enrollUrl', () => {
|
||||||
|
expect(() => AgentConfigSchema.parse({ ...base, enrollUrl: 'http://example.com/enroll' })).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a non-loopback localTargetUrl (anti-SSRF)', () => {
|
||||||
|
expect(() => AgentConfigSchema.parse({ ...base, localTargetUrl: 'ws://10.0.0.5:3000' })).toThrow()
|
||||||
|
expect(() => AgentConfigSchema.parse({ ...base, localTargetUrl: 'ws://evil.example.com:3000' })).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts loopback localTargetUrl variants', () => {
|
||||||
|
expect(isLoopbackWsUrl('ws://127.0.0.1:3000')).toBe(true)
|
||||||
|
expect(isLoopbackWsUrl('ws://localhost:3000')).toBe(true)
|
||||||
|
expect(isLoopbackWsUrl('ws://127.5.5.5:3000')).toBe(true)
|
||||||
|
expect(isLoopbackWsUrl('ws://10.0.0.5:3000')).toBe(false)
|
||||||
|
expect(isLoopbackWsUrl('wss://127.0.0.1:3000')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loadAgentConfig fails fast on a missing relayUrl', () => {
|
||||||
|
expect(() => loadAgentConfig({} as NodeJS.ProcessEnv, {})).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loadAgentConfig reads env and lets argv override', () => {
|
||||||
|
const cfg = loadAgentConfig(
|
||||||
|
{ RELAY_URL: 'wss://a/agent', ENROLL_URL: 'https://a/enroll' } as unknown as NodeJS.ProcessEnv,
|
||||||
|
{ subdomain: 'host-42' },
|
||||||
|
)
|
||||||
|
expect(cfg.relayUrl).toBe('wss://a/agent')
|
||||||
|
expect(cfg.subdomain).toBe('host-42')
|
||||||
|
expect(cfg.localTargetUrl).toBe('ws://127.0.0.1:3000')
|
||||||
|
})
|
||||||
|
})
|
||||||
62
agent/test/backoff.test.ts
Normal file
62
agent/test/backoff.test.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createBackoff, reconnectLoop, BACKOFF_CAP_MS } from '../src/transport/backoff.js'
|
||||||
|
import type { Tunnel } from '../src/transport/tunnel.js'
|
||||||
|
|
||||||
|
describe('createBackoff (T10)', () => {
|
||||||
|
it('follows 1s,2s,4s…cap 30s', () => {
|
||||||
|
const b = createBackoff()
|
||||||
|
const seq = [b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs()]
|
||||||
|
expect(seq).toEqual([1000, 2000, 4000, 8000, 16000, 30000, 30000])
|
||||||
|
expect(BACKOFF_CAP_MS).toBe(30_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reset() returns to 1s', () => {
|
||||||
|
const b = createBackoff()
|
||||||
|
b.nextDelayMs()
|
||||||
|
b.nextDelayMs()
|
||||||
|
b.reset()
|
||||||
|
expect(b.nextDelayMs()).toBe(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('jitter stays within [0.5×, 1×]', () => {
|
||||||
|
const b = createBackoff({ jitter: true, rng: () => 0 })
|
||||||
|
expect(b.nextDelayMs()).toBe(500) // 1000 * 0.5
|
||||||
|
const b2 = createBackoff({ jitter: true, rng: () => 1 })
|
||||||
|
expect(b2.nextDelayMs()).toBe(1000) // 1000 * 1.0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reconnectLoop (T10, INV12)', () => {
|
||||||
|
const fakeTunnel = {} as Tunnel
|
||||||
|
|
||||||
|
it('resolves on the first successful dial and resets backoff', async () => {
|
||||||
|
const dial = vi.fn().mockRejectedValueOnce(new Error('down')).mockResolvedValueOnce(fakeTunnel)
|
||||||
|
const backoff = createBackoff()
|
||||||
|
const reset = vi.spyOn(backoff, 'reset')
|
||||||
|
const sleeps: number[] = []
|
||||||
|
const result = await reconnectLoop(dial, backoff, () => false, async (ms) => {
|
||||||
|
sleeps.push(ms)
|
||||||
|
})
|
||||||
|
expect(result).toBe(fakeTunnel)
|
||||||
|
expect(sleeps).toEqual([1000])
|
||||||
|
expect(reset).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a revoked host does not reconnect (INV12)', async () => {
|
||||||
|
const dial = vi.fn().mockResolvedValue(fakeTunnel)
|
||||||
|
const result = await reconnectLoop(dial, createBackoff(), () => true, async () => {})
|
||||||
|
expect(dial).not.toHaveBeenCalled()
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops retrying once revoked mid-loop', async () => {
|
||||||
|
let revoked = false
|
||||||
|
const dial = vi.fn(async () => {
|
||||||
|
revoked = true
|
||||||
|
throw new Error('down')
|
||||||
|
})
|
||||||
|
const result = await reconnectLoop(dial, createBackoff(), () => revoked, async () => {})
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(dial).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
27
agent/test/buildBinary.test.ts
Normal file
27
agent/test/buildBinary.test.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { BINARY_TARGETS, buildBinaryConfig } from '../src/dist/buildBinary.js'
|
||||||
|
|
||||||
|
describe('buildBinaryConfig (T16)', () => {
|
||||||
|
it('produces a bun --compile spec per target with cli.ts entry', () => {
|
||||||
|
for (const target of BINARY_TARGETS) {
|
||||||
|
const spec = buildBinaryConfig(target)
|
||||||
|
expect(spec.tool).toBe('bun')
|
||||||
|
expect(spec.entry).toBe('src/cli.ts')
|
||||||
|
expect(spec.target).toBe(target)
|
||||||
|
expect(spec.bunTarget).toContain('bun-')
|
||||||
|
expect(spec.outfile).toContain(target)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('carries the INV11 forbidden-dep tripwire (no terminal parser in the bundle)', () => {
|
||||||
|
const spec = buildBinaryConfig('darwin-arm64')
|
||||||
|
expect(spec.forbiddenDeps).toContain('xterm')
|
||||||
|
expect(spec.forbiddenDeps).toContain('ansi')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('covers the four supported triples', () => {
|
||||||
|
expect([...BINARY_TARGETS].sort()).toEqual(
|
||||||
|
['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
111
agent/test/cli.test.ts
Normal file
111
agent/test/cli.test.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||||
|
import type { Keystore } from '../src/keys/keystore.js'
|
||||||
|
import type { AgentIdentity } from '../src/keys/identity.js'
|
||||||
|
import type { EnrollResult } from 'relay-contracts'
|
||||||
|
import { CliUsageError, parseArgs, runCli, type CliDeps } from '../src/cli.js'
|
||||||
|
|
||||||
|
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',
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeIdentity(): AgentIdentity {
|
||||||
|
return {
|
||||||
|
publicKey: new Uint8Array(32),
|
||||||
|
enrollFpr: 'fpr',
|
||||||
|
sign: () => new Uint8Array(64),
|
||||||
|
exportPrivatePkcs8Pem: () => 'PEM',
|
||||||
|
privateKeyObject: () => ({}) as never,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeKeystore(enrolled: boolean): Keystore {
|
||||||
|
return {
|
||||||
|
saveIdentity: vi.fn(),
|
||||||
|
loadIdentity: () => (enrolled ? fakeIdentity() : null),
|
||||||
|
saveCert: vi.fn(),
|
||||||
|
loadCert: () => (enrolled ? { certPem: 'C', caChainPem: 'CA' } : null),
|
||||||
|
saveContentSecret: vi.fn(),
|
||||||
|
loadContentSecret: () => null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps; out: string[] } {
|
||||||
|
const out: string[] = []
|
||||||
|
const d: CliDeps = {
|
||||||
|
loadConfig: () => CFG,
|
||||||
|
openKeystore: () => fakeKeystore(enrolled),
|
||||||
|
generateIdentity: fakeIdentity,
|
||||||
|
redeem: async (): Promise<EnrollResult> => ({
|
||||||
|
hostId: 'h-1',
|
||||||
|
subdomain: 'host-42',
|
||||||
|
cert: 'C',
|
||||||
|
caChain: 'CA',
|
||||||
|
hostContentSecret: new Uint8Array([1]),
|
||||||
|
}),
|
||||||
|
runTunnel: async () => 0,
|
||||||
|
installService: vi.fn(async () => {}),
|
||||||
|
uninstallService: vi.fn(async () => {}),
|
||||||
|
print: (l) => out.push(l),
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
return { d, out }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseArgs (T5)', () => {
|
||||||
|
it('parses `pair ABCD-1234`', () => {
|
||||||
|
expect(parseArgs(['pair', 'ABCD-1234'])).toEqual({ command: 'pair', code: 'ABCD-1234', flags: {} })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an unknown command', () => {
|
||||||
|
expect(() => parseArgs(['frobnicate'])).toThrow(CliUsageError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires a code on pair', () => {
|
||||||
|
expect(() => parseArgs(['pair'])).toThrow(CliUsageError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collects flags', () => {
|
||||||
|
expect(parseArgs(['pair', 'X', '--install'])).toEqual({
|
||||||
|
command: 'pair',
|
||||||
|
code: 'X',
|
||||||
|
flags: { install: true },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('runCli (T5)', () => {
|
||||||
|
it('pair happy path calls redeem and prints no secrets', async () => {
|
||||||
|
const { d, out } = deps()
|
||||||
|
const code = await runCli(parseArgs(['pair', 'ABCD']), d)
|
||||||
|
expect(code).toBe(0)
|
||||||
|
expect(out.join('\n')).toContain('host-42')
|
||||||
|
expect(out.join('\n')).not.toContain('PEM')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pair --install installs the service', async () => {
|
||||||
|
const install = vi.fn(async () => {})
|
||||||
|
const { d } = deps({ installService: install })
|
||||||
|
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
|
||||||
|
expect(install).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('run before pairing fails fast', async () => {
|
||||||
|
const { d } = deps({}, false)
|
||||||
|
await expect(runCli({ command: 'run', flags: {} }, d)).rejects.toBeInstanceOf(CliUsageError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('status prints no key/cert material (INV9)', async () => {
|
||||||
|
const { d, out } = deps({}, true)
|
||||||
|
await runCli({ command: 'status', flags: {} }, d)
|
||||||
|
const joined = out.join('\n')
|
||||||
|
expect(joined).toContain('subdomain: host-42')
|
||||||
|
expect(joined).not.toContain('PEM')
|
||||||
|
expect(joined).not.toContain('CA')
|
||||||
|
})
|
||||||
|
})
|
||||||
32
agent/test/csr.test.ts
Normal file
32
agent/test/csr.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { X509Certificate } from 'node:crypto'
|
||||||
|
import { generateIdentity } from '../src/keys/identity.js'
|
||||||
|
import { buildCsr } from '../src/enroll/csr.js'
|
||||||
|
|
||||||
|
describe('PKCS#10 CSR (T4)', () => {
|
||||||
|
it('emits a PEM CERTIFICATE REQUEST', () => {
|
||||||
|
const csr = buildCsr(generateIdentity(), 'host-42.term.example.com')
|
||||||
|
expect(csr).toContain('-----BEGIN CERTIFICATE REQUEST-----')
|
||||||
|
expect(csr).toContain('-----END CERTIFICATE REQUEST-----')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not contain private-key material (INV4)', () => {
|
||||||
|
const id = generateIdentity()
|
||||||
|
const csr = buildCsr(id, 'host-42')
|
||||||
|
expect(csr).not.toContain('PRIVATE KEY')
|
||||||
|
expect(csr).not.toContain(id.exportPrivatePkcs8Pem().split('\n')[1]!)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('produces a syntactically decodable DER structure', () => {
|
||||||
|
// Node can't parse a CSR directly, but the base64 body must be valid DER (a SEQUENCE).
|
||||||
|
const csr = buildCsr(generateIdentity(), 'host-42')
|
||||||
|
const b64 = csr
|
||||||
|
.replace(/-----[A-Z ]+-----/g, '')
|
||||||
|
.replace(/\s+/g, '')
|
||||||
|
const der = Buffer.from(b64, 'base64')
|
||||||
|
expect(der[0]).toBe(0x30) // outer SEQUENCE tag
|
||||||
|
expect(der.length).toBeGreaterThan(64)
|
||||||
|
// sanity: X509Certificate exists (crypto available) — unrelated smoke to keep import used
|
||||||
|
expect(typeof X509Certificate).toBe('function')
|
||||||
|
})
|
||||||
|
})
|
||||||
112
agent/test/dial.test.ts
Normal file
112
agent/test/dial.test.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||||
|
import { generateIdentity } from '../src/keys/identity.js'
|
||||||
|
import { openKeystore } from '../src/keys/keystore.js'
|
||||||
|
import {
|
||||||
|
CertExpiredError,
|
||||||
|
NotEnrolledError,
|
||||||
|
buildTlsOptions,
|
||||||
|
dialRelay,
|
||||||
|
type RawTlsWs,
|
||||||
|
type TlsWsConstructor,
|
||||||
|
} from '../src/transport/dial.js'
|
||||||
|
|
||||||
|
const CFG: AgentConfig = {
|
||||||
|
relayUrl: 'wss://relay.example.com/agent',
|
||||||
|
enrollUrl: 'https://example.com/enroll',
|
||||||
|
stateDir: '/tmp/x',
|
||||||
|
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||||
|
subdomain: 'host-42',
|
||||||
|
hostId: 'h-1',
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrolledKs() {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'wta-dial-'))
|
||||||
|
const ks = openKeystore(dir)
|
||||||
|
ks.saveIdentity(generateIdentity())
|
||||||
|
ks.saveCert('CERTPEM', 'CAPEM')
|
||||||
|
return { dir, ks }
|
||||||
|
}
|
||||||
|
|
||||||
|
const future: () => { validTo: Date } = () => ({ validTo: new Date(Date.now() + 86_400_000) })
|
||||||
|
const past: () => { validTo: Date } = () => ({ validTo: new Date(Date.now() - 1000) })
|
||||||
|
|
||||||
|
describe('buildTlsOptions (T12, INV14/INV4)', () => {
|
||||||
|
it('wires cert + key + CA and forces rejectUnauthorized true', () => {
|
||||||
|
const { dir, ks } = enrolledKs()
|
||||||
|
const tls = buildTlsOptions(ks, { certParser: future })
|
||||||
|
expect(tls.cert).toBe('CERTPEM')
|
||||||
|
expect(tls.ca).toBe('CAPEM')
|
||||||
|
expect(tls.key).toContain('PRIVATE KEY') // in-process key PEM
|
||||||
|
expect(tls.rejectUnauthorized).toBe(true)
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('carries NO bearer/agent token (mTLS is the auth)', () => {
|
||||||
|
const { dir, ks } = enrolledKs()
|
||||||
|
const tls = buildTlsOptions(ks, { certParser: future })
|
||||||
|
expect(JSON.stringify(tls)).not.toMatch(/token|authorization|bearer/i)
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('missing cert → NotEnrolledError (fail-fast)', () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'wta-dial-'))
|
||||||
|
expect(() => buildTlsOptions(openKeystore(dir))).toThrow(NotEnrolledError)
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('expired cert → CertExpiredError', () => {
|
||||||
|
const { dir, ks } = enrolledKs()
|
||||||
|
expect(() => buildTlsOptions(ks, { certParser: past })).toThrow(CertExpiredError)
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dialRelay (T12)', () => {
|
||||||
|
it('constructs the wss client with the TLS options and resolves on open', async () => {
|
||||||
|
const { dir, ks } = enrolledKs()
|
||||||
|
let capturedUrl = ''
|
||||||
|
let capturedOpts: unknown
|
||||||
|
class FakeTlsWs implements RawTlsWs {
|
||||||
|
constructor(url: string, opts: unknown) {
|
||||||
|
capturedUrl = url
|
||||||
|
capturedOpts = opts
|
||||||
|
queueMicrotask(() => this.openCb?.())
|
||||||
|
}
|
||||||
|
private openCb?: () => void
|
||||||
|
send(): void {}
|
||||||
|
close(): void {}
|
||||||
|
on(): void {}
|
||||||
|
once(ev: string, cb: () => void): void {
|
||||||
|
if (ev === 'open') this.openCb = cb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ws = await dialRelay(CFG, ks, { Ctor: FakeTlsWs as unknown as TlsWsConstructor, certParser: future })
|
||||||
|
expect(capturedUrl).toBe('wss://relay.example.com/agent')
|
||||||
|
expect((capturedOpts as { rejectUnauthorized: boolean }).rejectUnauthorized).toBe(true)
|
||||||
|
expect(ws).toBeDefined()
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when the server errors before open (bad chain / MITM)', async () => {
|
||||||
|
const { dir, ks } = enrolledKs()
|
||||||
|
class FakeTlsWs implements RawTlsWs {
|
||||||
|
private errCb?: (e: unknown) => void
|
||||||
|
constructor() {
|
||||||
|
queueMicrotask(() => this.errCb?.(new Error('unable to verify leaf signature')))
|
||||||
|
}
|
||||||
|
send(): void {}
|
||||||
|
close(): void {}
|
||||||
|
on(): void {}
|
||||||
|
once(ev: string, cb: (e: unknown) => void): void {
|
||||||
|
if (ev === 'error') this.errCb = cb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const p = dialRelay(CFG, ks, { Ctor: FakeTlsWs as unknown as TlsWsConstructor, certParser: future })
|
||||||
|
await expect(p).rejects.toThrow(/verify/)
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
63
agent/test/fixtures/fakes.ts
vendored
Normal file
63
agent/test/fixtures/fakes.ts
vendored
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { WsLike, TimerLike } from '../../src/transport/seams.js'
|
||||||
|
|
||||||
|
/** In-memory WsLike double: records sent frames, lets tests emit inbound events. */
|
||||||
|
export class FakeWs implements WsLike {
|
||||||
|
readonly sent: Uint8Array[] = []
|
||||||
|
closed = false
|
||||||
|
private readonly listeners = new Map<string, Array<(...a: unknown[]) => void>>()
|
||||||
|
|
||||||
|
send(d: Uint8Array): void {
|
||||||
|
this.sent.push(d)
|
||||||
|
}
|
||||||
|
on(ev: 'message' | 'close' | 'error', cb: (...a: unknown[]) => void): void {
|
||||||
|
const arr = this.listeners.get(ev) ?? []
|
||||||
|
arr.push(cb)
|
||||||
|
this.listeners.set(ev, arr)
|
||||||
|
}
|
||||||
|
close(): void {
|
||||||
|
this.closed = true
|
||||||
|
this.emit('close')
|
||||||
|
}
|
||||||
|
emit(ev: string, ...args: unknown[]): void {
|
||||||
|
for (const cb of this.listeners.get(ev) ?? []) cb(...args)
|
||||||
|
}
|
||||||
|
emitMessage(bytes: Uint8Array): void {
|
||||||
|
this.emit('message', bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic controllable timer for heartbeat/rotation tests. */
|
||||||
|
export class FakeTimer implements TimerLike {
|
||||||
|
private seq = 0
|
||||||
|
private readonly timeouts = new Map<number, { cb: () => void; ms: number }>()
|
||||||
|
private readonly intervals = new Map<number, { cb: () => void; ms: number }>()
|
||||||
|
|
||||||
|
setTimeout(cb: () => void, ms: number): unknown {
|
||||||
|
const id = this.seq++
|
||||||
|
this.timeouts.set(id, { cb, ms })
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
clearTimeout(handle: unknown): void {
|
||||||
|
this.timeouts.delete(handle as number)
|
||||||
|
}
|
||||||
|
setInterval(cb: () => void, ms: number): unknown {
|
||||||
|
const id = this.seq++
|
||||||
|
this.intervals.set(id, { cb, ms })
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
clearInterval(handle: unknown): void {
|
||||||
|
this.intervals.delete(handle as number)
|
||||||
|
}
|
||||||
|
/** Fire every armed timeout whose delay is ≤ ms (single shot) and every interval once. */
|
||||||
|
advance(ms: number): void {
|
||||||
|
for (const [id, t] of [...this.timeouts]) {
|
||||||
|
if (t.ms <= ms) {
|
||||||
|
this.timeouts.delete(id)
|
||||||
|
t.cb()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const t of [...this.intervals.values()]) {
|
||||||
|
if (t.ms <= ms) t.cb()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
agent/test/flowControl.test.ts
Normal file
37
agent/test/flowControl.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { createFlowController } from '../src/transport/flowControl.js'
|
||||||
|
|
||||||
|
describe('FlowController (T11)', () => {
|
||||||
|
it('pauses at zero credit and resumes on grant', () => {
|
||||||
|
const fc = createFlowController()
|
||||||
|
fc.initWindow(1, 10)
|
||||||
|
expect(fc.consume(1, 8)).toBe(true)
|
||||||
|
expect(fc.consume(1, 8)).toBe(false) // only 2 credit left → pause
|
||||||
|
fc.grant(1, 16)
|
||||||
|
expect(fc.consume(1, 8)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('credit is per-stream: exhausting A does not pause B (starvation guard)', () => {
|
||||||
|
const fc = createFlowController()
|
||||||
|
fc.initWindow(1, 4)
|
||||||
|
fc.initWindow(2, 100)
|
||||||
|
expect(fc.consume(1, 4)).toBe(true)
|
||||||
|
expect(fc.consume(1, 1)).toBe(false) // A exhausted
|
||||||
|
expect(fc.consume(2, 50)).toBe(true) // B unaffected
|
||||||
|
})
|
||||||
|
|
||||||
|
it('connection-level (streamId 0) credit caps the whole link', () => {
|
||||||
|
const fc = createFlowController()
|
||||||
|
fc.initWindow(0, 5) // connection window
|
||||||
|
fc.initWindow(1, 1000)
|
||||||
|
expect(fc.consume(1, 5)).toBe(true)
|
||||||
|
expect(fc.consume(1, 1)).toBe(false) // connection window exhausted despite stream credit
|
||||||
|
fc.grant(0, 10)
|
||||||
|
expect(fc.consume(1, 1)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('an uninitialized stream has no credit', () => {
|
||||||
|
const fc = createFlowController()
|
||||||
|
expect(fc.consume(42, 1)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
53
agent/test/frpScaffold.test.ts
Normal file
53
agent/test/frpScaffold.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||||
|
import {
|
||||||
|
buildFrpcToml,
|
||||||
|
isFrpRetired,
|
||||||
|
spawnFrpc,
|
||||||
|
type ChildLike,
|
||||||
|
} from '../src/transport/frpScaffold.js'
|
||||||
|
|
||||||
|
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: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('frpScaffold (T6, v0.8 only)', () => {
|
||||||
|
it('generates a loopback-only tls frpc.toml', () => {
|
||||||
|
const toml = buildFrpcToml(CFG)
|
||||||
|
expect(toml).toContain('local_ip = "127.0.0.1"')
|
||||||
|
expect(toml).toContain('local_port = 3000')
|
||||||
|
expect(toml).toContain('subdomain = "host-42"')
|
||||||
|
expect(toml).toContain('tls_enable = true')
|
||||||
|
expect(toml).not.toMatch(/local_ip = "(?!127\.0\.0\.1)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a non-loopback local target (anti-SSRF)', () => {
|
||||||
|
expect(() => buildFrpcToml({ ...CFG, localTargetUrl: 'ws://10.0.0.5:3000' })).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('propagates child exit to onExit', async () => {
|
||||||
|
let exitHandler: ((code: number | null) => void) | null = null
|
||||||
|
const child: ChildLike = {
|
||||||
|
on: (_ev, cb) => {
|
||||||
|
exitHandler = cb
|
||||||
|
},
|
||||||
|
kill: vi.fn(),
|
||||||
|
}
|
||||||
|
const scaffold = spawnFrpc(CFG, '/usr/bin/frpc', () => child)
|
||||||
|
const seen: number[] = []
|
||||||
|
scaffold.onExit((c) => seen.push(c))
|
||||||
|
await scaffold.start()
|
||||||
|
exitHandler!(7)
|
||||||
|
expect(seen).toEqual([7])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retirement guard: retired once ed25519', () => {
|
||||||
|
expect(isFrpRetired('ed25519')).toBe(true)
|
||||||
|
expect(isFrpRetired('token')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
58
agent/test/heartbeat.test.ts
Normal file
58
agent/test/heartbeat.test.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { decodeMuxFrame } from 'relay-contracts'
|
||||||
|
import { holdTunnel } from '../src/transport/tunnel.js'
|
||||||
|
import { createHeartbeat, HEARTBEAT_INTERVAL_MS } from '../src/transport/heartbeat.js'
|
||||||
|
import { FakeWs, FakeTimer } from './fixtures/fakes.js'
|
||||||
|
|
||||||
|
describe('Heartbeat (T9, §4.1)', () => {
|
||||||
|
it('replies PONG echoing the inbound PING token byte-exact', () => {
|
||||||
|
const ws = new FakeWs()
|
||||||
|
const tunnel = holdTunnel(ws)
|
||||||
|
const hb = createHeartbeat(tunnel, { timer: new FakeTimer() })
|
||||||
|
const token = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])
|
||||||
|
hb.onPing(token)
|
||||||
|
const frame = decodeMuxFrame(ws.sent.at(-1)!)
|
||||||
|
expect(frame.header.type).toBe('pong')
|
||||||
|
expect(Buffer.from(frame.payload).equals(Buffer.from(token))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fires onDead when no PONG arrives within the interval', () => {
|
||||||
|
const ws = new FakeWs()
|
||||||
|
const tunnel = holdTunnel(ws)
|
||||||
|
const timer = new FakeTimer()
|
||||||
|
const hb = createHeartbeat(tunnel, { timer, intervalMs: 1000 })
|
||||||
|
let dead = false
|
||||||
|
hb.onDead(() => {
|
||||||
|
dead = true
|
||||||
|
})
|
||||||
|
hb.start() // sends first ping, arms deadline
|
||||||
|
timer.advance(1000) // deadline fires, no pong seen
|
||||||
|
expect(dead).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fire onDead when PONG arrives in time', () => {
|
||||||
|
const ws = new FakeWs()
|
||||||
|
const tunnel = holdTunnel(ws)
|
||||||
|
const timer = new FakeTimer()
|
||||||
|
const hb = createHeartbeat(tunnel, { timer, intervalMs: 1000, genToken: () => new Uint8Array(8) })
|
||||||
|
let dead = false
|
||||||
|
hb.onDead(() => {
|
||||||
|
dead = true
|
||||||
|
})
|
||||||
|
hb.start()
|
||||||
|
hb.onPong(new Uint8Array(8)) // clears pending before the deadline
|
||||||
|
timer.advance(1000)
|
||||||
|
expect(dead).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exposes the frozen 15s interval', () => {
|
||||||
|
expect(HEARTBEAT_INTERVAL_MS).toBe(15_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stop() cancels timers (no leak)', () => {
|
||||||
|
const ws = new FakeWs()
|
||||||
|
const hb = createHeartbeat(holdTunnel(ws), { timer: new FakeTimer(), intervalMs: 1000 })
|
||||||
|
hb.start()
|
||||||
|
expect(() => hb.stop()).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
146
agent/test/hostEndpoint.test.ts
Normal file
146
agent/test/hostEndpoint.test.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import type {
|
||||||
|
AeadAlg,
|
||||||
|
ClientHello,
|
||||||
|
E2ESession,
|
||||||
|
HandshakeResult,
|
||||||
|
HostHello,
|
||||||
|
} from 'relay-contracts'
|
||||||
|
import { generateIdentity } from '../src/keys/identity.js'
|
||||||
|
import {
|
||||||
|
MitmAbortError,
|
||||||
|
createE2ETransform,
|
||||||
|
makeHostHello,
|
||||||
|
type CreateHostHandshake,
|
||||||
|
type VerifyDeviceProof,
|
||||||
|
} from '../src/e2e/hostEndpoint.js'
|
||||||
|
import type { ReplaySealer } from '../src/e2e/replaySeal.js'
|
||||||
|
|
||||||
|
const ALG: AeadAlg = 'aes-256-gcm'
|
||||||
|
|
||||||
|
function clientHello(proof: string): ClientHello {
|
||||||
|
return {
|
||||||
|
clientEphPub: new Uint8Array([1, 2, 3]),
|
||||||
|
clientNonce: new Uint8Array([4, 5, 6]),
|
||||||
|
aeadOffer: [ALG],
|
||||||
|
deviceAuthProof: proof,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeHandshake(keysByte: number): CreateHostHandshake {
|
||||||
|
return () => ({
|
||||||
|
async respond(): Promise<{ hello: HostHello; result: HandshakeResult }> {
|
||||||
|
const result: HandshakeResult = {
|
||||||
|
keys: {
|
||||||
|
c2h: new Uint8Array([keysByte]) as never,
|
||||||
|
h2c: new Uint8Array([keysByte + 1]) as never,
|
||||||
|
},
|
||||||
|
aead: ALG,
|
||||||
|
transcript: new Uint8Array([0xff]),
|
||||||
|
}
|
||||||
|
const hello: HostHello = {
|
||||||
|
hostEphPub: new Uint8Array([7]),
|
||||||
|
hostNonce: new Uint8Array([8]),
|
||||||
|
aeadChoice: ALG,
|
||||||
|
enrollFpr: 'fpr',
|
||||||
|
sig: new Uint8Array([9]),
|
||||||
|
}
|
||||||
|
return { hello, result }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The load-bearing MITM verifier: only a specific proof passes.
|
||||||
|
const realVerifier: VerifyDeviceProof = async (proof) => proof === 'VALID-PROOF'
|
||||||
|
|
||||||
|
describe('makeHostHello (T15, anti-MITM)', () => {
|
||||||
|
it('derives DirectionalKeys{c2h,h2c} on a valid proof (FIX 2 — no single sessionKey)', async () => {
|
||||||
|
const { result } = await makeHostHello(clientHello('VALID-PROOF'), generateIdentity(), realVerifier, {
|
||||||
|
createHostHandshake: fakeHandshake(0x10),
|
||||||
|
})
|
||||||
|
expect(result.keys).toHaveProperty('c2h')
|
||||||
|
expect(result.keys).toHaveProperty('h2c')
|
||||||
|
expect(result).not.toHaveProperty('sessionKey')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ABORTS on a forged proof: no keys, no HostHello (MitmAbortError)', async () => {
|
||||||
|
const respond = vi.fn()
|
||||||
|
const spyHandshake: CreateHostHandshake = () => ({ respond: respond as never })
|
||||||
|
await expect(
|
||||||
|
makeHostHello(clientHello('FORGED'), generateIdentity(), realVerifier, {
|
||||||
|
createHostHandshake: spyHandshake,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(MitmAbortError)
|
||||||
|
expect(respond).not.toHaveBeenCalled() // no key derivation reached
|
||||||
|
})
|
||||||
|
|
||||||
|
it('no-stub guard: swapping the verifier for always-true makes the MITM test FAIL', async () => {
|
||||||
|
const stub: VerifyDeviceProof = async () => true
|
||||||
|
// With the stubbed verifier, the forged proof WRONGLY completes — proving the verifier is
|
||||||
|
// load-bearing (a real build forbids this stub via the import guard below).
|
||||||
|
const out = await makeHostHello(clientHello('FORGED'), generateIdentity(), stub, {
|
||||||
|
createHostHandshake: fakeHandshake(0x20),
|
||||||
|
})
|
||||||
|
expect(out.result.keys).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('no-stub CI guard: hostEndpoint.ts imports NO verifier from relay-e2e (FIX 6b)', () => {
|
||||||
|
const raw = readFileSync(join(import.meta.dirname, '..', 'src', 'e2e', 'hostEndpoint.ts'), 'utf8')
|
||||||
|
const src = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '') // strip comments
|
||||||
|
expect(src).not.toMatch(/import[^\n]*verifyDeviceAuthProof/)
|
||||||
|
expect(src).not.toMatch(/from ['"]relay-e2e['"]/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createE2ETransform (T15)', () => {
|
||||||
|
function fakeSession(): E2ESession {
|
||||||
|
// Reversible transform that HIDES the plaintext (XOR) so INV2 assertions are meaningful.
|
||||||
|
return {
|
||||||
|
role: 'host',
|
||||||
|
seal: (pt) => Uint8Array.from([0xe0, ...pt.map((b) => b ^ 0x55)]),
|
||||||
|
open: (ct) => Uint8Array.from(ct.slice(1)).map((b) => b ^ 0x55),
|
||||||
|
rederive: () => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function fakeReplay(): ReplaySealer & { calls: number } {
|
||||||
|
const r = {
|
||||||
|
calls: 0,
|
||||||
|
seal(_pt: Uint8Array) {
|
||||||
|
r.calls += 1
|
||||||
|
return { seq: 0n, nonce: new Uint8Array(), ciphertext: new Uint8Array([0xde]), tag: new Uint8Array() }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
it('outbound seals under BOTH the live session and the replay key (FIX 3, INV2)', () => {
|
||||||
|
const replay = fakeReplay()
|
||||||
|
const t = createE2ETransform(generateIdentity(), realVerifier, replay)
|
||||||
|
t.openStream(1)
|
||||||
|
t.seedSession(1, fakeSession(), new Uint8Array([0x48])) // HostHello control frame
|
||||||
|
const marker = new TextEncoder().encode('PLAIN')
|
||||||
|
const sealed = t.outbound(1, marker)
|
||||||
|
expect(replay.calls).toBe(1) // replay-bound seal happened
|
||||||
|
expect(Buffer.from(sealed).includes(Buffer.from(marker))).toBe(false) // ciphertext, not plaintext
|
||||||
|
expect(t.takeControlFrames!(1)).toEqual([new Uint8Array([0x48])]) // HostHello flushed once
|
||||||
|
expect(t.takeControlFrames!(1)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inbound before seeding returns null (handshake pending); after, opens opaque', () => {
|
||||||
|
const t = createE2ETransform(generateIdentity(), realVerifier, fakeReplay())
|
||||||
|
const session = fakeSession()
|
||||||
|
t.openStream(1)
|
||||||
|
expect(t.inbound(1, new Uint8Array([1, 2]))).toBeNull()
|
||||||
|
t.seedSession(1, session, new Uint8Array([0x48]))
|
||||||
|
const ct = session.seal(new Uint8Array([9, 9])) // c2h ciphertext
|
||||||
|
expect([...t.inbound(1, ct)!]).toEqual([9, 9])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('outbound before the session is established aborts (no silent plaintext leak)', () => {
|
||||||
|
const t = createE2ETransform(generateIdentity(), realVerifier, fakeReplay())
|
||||||
|
t.openStream(1)
|
||||||
|
expect(() => t.outbound(1, new Uint8Array([1]))).toThrow(MitmAbortError)
|
||||||
|
})
|
||||||
|
})
|
||||||
53
agent/test/identity.test.ts
Normal file
53
agent/test/identity.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import {
|
||||||
|
computeEnrollFpr,
|
||||||
|
generateIdentity,
|
||||||
|
identityFromPrivatePem,
|
||||||
|
verifySignature,
|
||||||
|
} from '../src/keys/identity.js'
|
||||||
|
|
||||||
|
describe('AgentIdentity (INV4)', () => {
|
||||||
|
it('generates distinct keypairs', () => {
|
||||||
|
const a = generateIdentity()
|
||||||
|
const b = generateIdentity()
|
||||||
|
expect(Buffer.from(a.publicKey).equals(Buffer.from(b.publicKey))).toBe(false)
|
||||||
|
expect(a.publicKey.length).toBe(32)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sign/verify round-trips', () => {
|
||||||
|
const id = generateIdentity()
|
||||||
|
const msg = new TextEncoder().encode('transcript-bytes')
|
||||||
|
const sig = id.sign(msg)
|
||||||
|
expect(verifySignature(id.publicKey, msg, sig)).toBe(true)
|
||||||
|
expect(verifySignature(id.publicKey, new TextEncoder().encode('tampered'), sig)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enrollFpr is deterministic base64url(SHA-256(pubkey))', () => {
|
||||||
|
const id = generateIdentity()
|
||||||
|
expect(computeEnrollFpr(id.publicKey)).toBe(id.enrollFpr)
|
||||||
|
expect(id.enrollFpr).not.toMatch(/[+/=]/) // base64url alphabet only
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reloads the same identity from PEM', () => {
|
||||||
|
const id = generateIdentity()
|
||||||
|
const pem = id.exportPrivatePkcs8Pem()
|
||||||
|
const reloaded = identityFromPrivatePem(pem)
|
||||||
|
expect(Buffer.from(reloaded.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||||
|
expect(reloaded.enrollFpr).toBe(id.enrollFpr)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('security: no API returns raw private-key bytes', () => {
|
||||||
|
const id = generateIdentity()
|
||||||
|
// The only private-key surface is a PEM export for the 0600 keystore and an in-process
|
||||||
|
// KeyObject; there is NO Uint8Array/raw-bytes getter for the private key.
|
||||||
|
expect('privateKey' in id).toBe(false)
|
||||||
|
const src = readFileSync(
|
||||||
|
join(import.meta.dirname, '..', 'src', 'keys', 'identity.ts'),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
// No function exports raw private key bytes onto the network surface.
|
||||||
|
expect(src).not.toMatch(/exportPrivateRaw|privateKeyBytes|toRawPrivate/)
|
||||||
|
})
|
||||||
|
})
|
||||||
76
agent/test/install.test.ts
Normal file
76
agent/test/install.test.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||||
|
import {
|
||||||
|
RootRefusedError,
|
||||||
|
detectPlatform,
|
||||||
|
installService,
|
||||||
|
uninstallService,
|
||||||
|
type InstallDeps,
|
||||||
|
} from '../src/service/install.js'
|
||||||
|
|
||||||
|
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',
|
||||||
|
}
|
||||||
|
|
||||||
|
function deps(uid = 501): InstallDeps & { writes: Array<[string, string]>; runs: Array<[string, readonly string[]]> } {
|
||||||
|
const writes: Array<[string, string]> = []
|
||||||
|
const runs: Array<[string, readonly string[]]> = []
|
||||||
|
return {
|
||||||
|
writes,
|
||||||
|
runs,
|
||||||
|
writeFile: (p, c) => writes.push([p, c]),
|
||||||
|
runCommand: async (cmd, args) => {
|
||||||
|
runs.push([cmd, args])
|
||||||
|
},
|
||||||
|
getuid: () => uid,
|
||||||
|
homedir: () => '/home/alice',
|
||||||
|
username: () => 'alice',
|
||||||
|
binPath: () => '/usr/local/bin/web-terminal-agent',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('detectPlatform (T17)', () => {
|
||||||
|
it('maps darwin→launchd, linux→systemd, else null', () => {
|
||||||
|
expect(detectPlatform('darwin')).toBe('launchd')
|
||||||
|
expect(detectPlatform('linux')).toBe('systemd')
|
||||||
|
expect(detectPlatform('win32')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('installService (T17)', () => {
|
||||||
|
it('refuses to install as root (negative, least privilege)', async () => {
|
||||||
|
await expect(installService(CFG, 'systemd', deps(0))).rejects.toBeInstanceOf(RootRefusedError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('systemd: writes a run-as-user unit and enables it', async () => {
|
||||||
|
const d = deps()
|
||||||
|
await installService(CFG, 'systemd', d)
|
||||||
|
const [, unit] = d.writes[0]!
|
||||||
|
expect(unit).toContain('ExecStart=/usr/local/bin/web-terminal-agent run')
|
||||||
|
expect(unit).toContain('User=alice')
|
||||||
|
expect(unit).not.toContain('User=root')
|
||||||
|
expect(unit).toContain('Restart=on-failure')
|
||||||
|
expect(d.runs[0]![0]).toBe('systemctl')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('launchd: writes a plist with ProgramArguments run + KeepAlive', async () => {
|
||||||
|
const d = deps()
|
||||||
|
await installService(CFG, 'launchd', d)
|
||||||
|
const [path, plist] = d.writes[0]!
|
||||||
|
expect(path).toContain('LaunchAgents')
|
||||||
|
expect(plist).toContain('<string>run</string>')
|
||||||
|
expect(plist).toContain('<key>KeepAlive</key>')
|
||||||
|
expect(d.runs[0]![0]).toBe('launchctl')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uninstall unloads cleanly', async () => {
|
||||||
|
const d = deps()
|
||||||
|
await uninstallService('launchd', d)
|
||||||
|
expect(d.runs[0]).toEqual(['launchctl', ['unload', '/home/alice/Library/LaunchAgents/com.web-terminal.agent.plist']])
|
||||||
|
})
|
||||||
|
})
|
||||||
70
agent/test/keystore.test.ts
Normal file
70
agent/test/keystore.test.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { generateIdentity } from '../src/keys/identity.js'
|
||||||
|
import { KeystoreError, openKeystore } from '../src/keys/keystore.js'
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
function freshDir(): string {
|
||||||
|
const d = mkdtempSync(join(tmpdir(), 'wta-ks-'))
|
||||||
|
dirs.push(d)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
afterEach(() => {
|
||||||
|
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
function mode(path: string): number {
|
||||||
|
return statSync(path).mode & 0o777
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Keystore (INV4/INV5)', () => {
|
||||||
|
it('persists the private key 0600 and reloads it', () => {
|
||||||
|
const dir = freshDir()
|
||||||
|
const ks = openKeystore(dir)
|
||||||
|
const id = generateIdentity()
|
||||||
|
ks.saveIdentity(id)
|
||||||
|
expect(mode(join(dir, 'agent.key.pem'))).toBe(0o600)
|
||||||
|
const reloaded = ks.loadIdentity()
|
||||||
|
expect(reloaded).not.toBeNull()
|
||||||
|
expect(Buffer.from(reloaded!.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists cert + CA chain 0600', () => {
|
||||||
|
const dir = freshDir()
|
||||||
|
const ks = openKeystore(dir)
|
||||||
|
ks.saveCert('CERTPEM', 'CAPEM')
|
||||||
|
expect(mode(join(dir, 'agent.cert.pem'))).toBe(0o600)
|
||||||
|
expect(mode(join(dir, 'agent.ca.pem'))).toBe(0o600)
|
||||||
|
expect(ks.loadCert()).toEqual({ certPem: 'CERTPEM', caChainPem: 'CAPEM' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FIX 3: round-trips hostContentSecret 0600', () => {
|
||||||
|
const dir = freshDir()
|
||||||
|
const ks = openKeystore(dir)
|
||||||
|
const secret = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])
|
||||||
|
ks.saveContentSecret(secret)
|
||||||
|
expect(mode(join(dir, 'content.secret'))).toBe(0o600)
|
||||||
|
expect(Buffer.from(ks.loadContentSecret()!).equals(Buffer.from(secret))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when nothing is stored yet', () => {
|
||||||
|
const ks = openKeystore(freshDir())
|
||||||
|
expect(ks.loadIdentity()).toBeNull()
|
||||||
|
expect(ks.loadCert()).toBeNull()
|
||||||
|
expect(ks.loadContentSecret()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws a typed error on a corrupt key file', () => {
|
||||||
|
const dir = freshDir()
|
||||||
|
writeFileSync(join(dir, 'agent.key.pem'), 'not-a-pem')
|
||||||
|
expect(() => openKeystore(dir).loadIdentity()).toThrow(KeystoreError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses to write into a group/world-accessible dir (INV5)', () => {
|
||||||
|
const dir = join(freshDir(), 'wideopen')
|
||||||
|
mkdirSync(dir, { mode: 0o755 })
|
||||||
|
expect(() => openKeystore(dir).saveIdentity(generateIdentity())).toThrow(KeystoreError)
|
||||||
|
})
|
||||||
|
})
|
||||||
43
agent/test/logger.test.ts
Normal file
43
agent/test/logger.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { createLogger } from '../src/log/logger.js'
|
||||||
|
|
||||||
|
function capture() {
|
||||||
|
const lines: string[] = []
|
||||||
|
return { lines, sink: (l: string) => lines.push(l) }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('redacting logger (INV9)', () => {
|
||||||
|
it('never emits secret meta values', () => {
|
||||||
|
const { lines, sink } = capture()
|
||||||
|
const log = createLogger('debug', sink)
|
||||||
|
log.log('info', 'enrolled', {
|
||||||
|
privateKey: 'SECRET-PRIV-KEY',
|
||||||
|
cert: 'SECRET-CERT',
|
||||||
|
pairingCode: 'ABCD-1234',
|
||||||
|
agentToken: 'tok-xyz',
|
||||||
|
})
|
||||||
|
const joined = lines.join('\n')
|
||||||
|
expect(joined).not.toContain('SECRET-PRIV-KEY')
|
||||||
|
expect(joined).not.toContain('SECRET-CERT')
|
||||||
|
expect(joined).not.toContain('ABCD-1234')
|
||||||
|
expect(joined).not.toContain('tok-xyz')
|
||||||
|
expect(joined).toContain('[REDACTED]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes non-secret meta (nonce) through', () => {
|
||||||
|
const { lines, sink } = capture()
|
||||||
|
const log = createLogger('debug', sink)
|
||||||
|
log.log('debug', 'frame', { nonce: 'abc123', streamId: 7 })
|
||||||
|
expect(lines[0]).toContain('abc123')
|
||||||
|
expect(lines[0]).toContain('7')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops messages below the threshold level', () => {
|
||||||
|
const { lines, sink } = capture()
|
||||||
|
const log = createLogger('warn', sink)
|
||||||
|
log.log('debug', 'noisy')
|
||||||
|
log.log('error', 'boom')
|
||||||
|
expect(lines).toHaveLength(1)
|
||||||
|
expect(lines[0]).toContain('boom')
|
||||||
|
})
|
||||||
|
})
|
||||||
49
agent/test/loopback.test.ts
Normal file
49
agent/test/loopback.test.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { buildLoopbackUrl, dialLoopback, type RawWs, type WsConstructor } from '../src/transport/loopback.js'
|
||||||
|
|
||||||
|
describe('buildLoopbackUrl (T8)', () => {
|
||||||
|
it('joins target + path without a double slash', () => {
|
||||||
|
expect(buildLoopbackUrl('ws://127.0.0.1:3000', '/term?join=x')).toBe('ws://127.0.0.1:3000/term?join=x')
|
||||||
|
expect(buildLoopbackUrl('ws://127.0.0.1:3000/', 'term')).toBe('ws://127.0.0.1:3000/term')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
class FakeRawWs implements RawWs {
|
||||||
|
static last: FakeRawWs | null = null
|
||||||
|
readonly url: string
|
||||||
|
readonly origin: string | undefined
|
||||||
|
private readonly handlers = new Map<string, (...a: unknown[]) => void>()
|
||||||
|
constructor(url: string, opts?: { headers?: Record<string, string> }) {
|
||||||
|
this.url = url
|
||||||
|
this.origin = opts?.headers?.['Origin']
|
||||||
|
FakeRawWs.last = this
|
||||||
|
}
|
||||||
|
send(): void {}
|
||||||
|
close(): void {}
|
||||||
|
on(): void {}
|
||||||
|
once(event: string, cb: (...a: unknown[]) => void): void {
|
||||||
|
this.handlers.set(event, cb)
|
||||||
|
}
|
||||||
|
fire(event: string, ...args: unknown[]): void {
|
||||||
|
this.handlers.get(event)?.(...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('dialLoopback (T8)', () => {
|
||||||
|
it('replays Origin and resolves on open', async () => {
|
||||||
|
const dial = dialLoopback('ws://127.0.0.1:3000', FakeRawWs as unknown as WsConstructor)
|
||||||
|
const promise = dial('/term?join=x', 'https://host-42.term.example.com')
|
||||||
|
FakeRawWs.last!.fire('open')
|
||||||
|
const ws = await promise
|
||||||
|
expect(FakeRawWs.last!.url).toBe('ws://127.0.0.1:3000/term?join=x')
|
||||||
|
expect(FakeRawWs.last!.origin).toBe('https://host-42.term.example.com')
|
||||||
|
expect(ws).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects on a pre-open error (base app down)', async () => {
|
||||||
|
const dial = dialLoopback('ws://127.0.0.1:3000', FakeRawWs as unknown as WsConstructor)
|
||||||
|
const promise = dial('/term', 'https://x')
|
||||||
|
FakeRawWs.last!.fire('error', new Error('ECONNREFUSED'))
|
||||||
|
await expect(promise).rejects.toThrow('ECONNREFUSED')
|
||||||
|
})
|
||||||
|
})
|
||||||
43
agent/test/originConfig.test.ts
Normal file
43
agent/test/originConfig.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { ensureAllowedOrigin, subdomainOrigin, type OriginFsDeps } from '../src/service/originConfig.js'
|
||||||
|
|
||||||
|
function memFs(initial: string | null): { fs: OriginFsDeps; get(): string } {
|
||||||
|
const store = { content: initial }
|
||||||
|
const fs: OriginFsDeps = {
|
||||||
|
exists: () => store.content !== null,
|
||||||
|
read: () => store.content ?? '',
|
||||||
|
write: (_p, c) => {
|
||||||
|
store.content = c
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return { fs, get: () => store.content ?? '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const PATH = '/etc/web-terminal.env'
|
||||||
|
|
||||||
|
describe('ensureAllowedOrigin (T17, EXPLORE §3)', () => {
|
||||||
|
it('composes https://<subdomain>.term.<domain>', () => {
|
||||||
|
expect(subdomainOrigin('host-42', 'example.com')).toBe('https://host-42.term.example.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appends the origin when the file has none', () => {
|
||||||
|
const { fs, get } = memFs('PORT=3000\n')
|
||||||
|
ensureAllowedOrigin(PATH, 'host-42', 'example.com', fs)
|
||||||
|
expect(get()).toContain('ALLOWED_ORIGINS=https://host-42.term.example.com')
|
||||||
|
expect(get()).toContain('PORT=3000')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is idempotent (no duplicate on a second run)', () => {
|
||||||
|
const { fs, get } = memFs('ALLOWED_ORIGINS=https://host-42.term.example.com\n')
|
||||||
|
ensureAllowedOrigin(PATH, 'host-42', 'example.com', fs)
|
||||||
|
const matches = get().match(/host-42\.term\.example\.com/g) ?? []
|
||||||
|
expect(matches).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves existing origins (never weakens the Origin check)', () => {
|
||||||
|
const { fs, get } = memFs('ALLOWED_ORIGINS=https://existing.example.com\n')
|
||||||
|
ensureAllowedOrigin(PATH, 'host-42', 'example.com', fs)
|
||||||
|
expect(get()).toContain('https://existing.example.com')
|
||||||
|
expect(get()).toContain('https://host-42.term.example.com')
|
||||||
|
})
|
||||||
|
})
|
||||||
105
agent/test/pair.test.ts
Normal file
105
agent/test/pair.test.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { encodeBase64UrlBytes } from 'relay-contracts'
|
||||||
|
import { generateIdentity } from '../src/keys/identity.js'
|
||||||
|
import { openKeystore } from '../src/keys/keystore.js'
|
||||||
|
import {
|
||||||
|
DEFAULT_ENROLL_MODE,
|
||||||
|
EnrollError,
|
||||||
|
PairingCodeExpiredError,
|
||||||
|
PairingCodeSpentError,
|
||||||
|
redeemPairingCode,
|
||||||
|
} from '../src/enroll/pair.js'
|
||||||
|
|
||||||
|
const ENROLL_URL = 'https://example.com/enroll'
|
||||||
|
const VALID_UUID = '11111111-1111-4111-8111-111111111111'
|
||||||
|
|
||||||
|
function freshKs() {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'wta-pair-'))
|
||||||
|
return { dir, ks: openKeystore(dir) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(status: number, body: unknown): Response {
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: async () => body,
|
||||||
|
} as unknown as Response
|
||||||
|
}
|
||||||
|
|
||||||
|
function okBody(wrappedSecret: Uint8Array) {
|
||||||
|
return {
|
||||||
|
hostId: VALID_UUID,
|
||||||
|
subdomain: 'host-42',
|
||||||
|
cert: 'CERTPEM',
|
||||||
|
caChain: 'CAPEM',
|
||||||
|
hostContentSecret: encodeBase64UrlBytes(wrappedSecret),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('redeemPairingCode (§4.5, T4)', () => {
|
||||||
|
it('defaults to ed25519 mode', () => {
|
||||||
|
expect(DEFAULT_ENROLL_MODE).toBe('ed25519')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends only pubkey + csr, never the private key (INV4)', async () => {
|
||||||
|
const { dir, ks } = freshKs()
|
||||||
|
const id = generateIdentity()
|
||||||
|
const fetchImpl = vi.fn(async (_u: string | URL | Request, init?: RequestInit) => {
|
||||||
|
const body = JSON.parse(String(init!.body)) as Record<string, unknown>
|
||||||
|
expect(body).toHaveProperty('agentPubkey')
|
||||||
|
expect(body).toHaveProperty('csr')
|
||||||
|
expect(JSON.stringify(body)).not.toContain('PRIVATE KEY')
|
||||||
|
expect(body).not.toHaveProperty('privateKey')
|
||||||
|
return jsonResponse(200, okBody(new Uint8Array([9, 9, 9])))
|
||||||
|
})
|
||||||
|
await redeemPairingCode(ENROLL_URL, 'ABCD-1234', id, ks, { fetchImpl: fetchImpl as unknown as typeof fetch })
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores cert + unwrapped content secret; wrapped bytes not persisted (FIX 3)', async () => {
|
||||||
|
const { dir, ks } = freshKs()
|
||||||
|
const id = generateIdentity()
|
||||||
|
const wrapped = new Uint8Array([1, 2, 3])
|
||||||
|
const unwrapped = new Uint8Array([7, 7, 7])
|
||||||
|
const fetchImpl = async () => jsonResponse(200, okBody(wrapped))
|
||||||
|
await redeemPairingCode(ENROLL_URL, 'ABCD-1234', id, ks, {
|
||||||
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||||
|
unwrapContentSecret: () => unwrapped,
|
||||||
|
})
|
||||||
|
expect(ks.loadCert()).toEqual({ certPem: 'CERTPEM', caChainPem: 'CAPEM' })
|
||||||
|
const stored = ks.loadContentSecret()!
|
||||||
|
expect(Buffer.from(stored).equals(Buffer.from(unwrapped))).toBe(true)
|
||||||
|
expect(Buffer.from(stored).equals(Buffer.from(wrapped))).toBe(false)
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps 409 → PairingCodeSpentError (single-use)', async () => {
|
||||||
|
const { dir, ks } = freshKs()
|
||||||
|
const fetchImpl = async () => jsonResponse(409, {})
|
||||||
|
await expect(
|
||||||
|
redeemPairingCode(ENROLL_URL, 'X', generateIdentity(), ks, { fetchImpl: fetchImpl as unknown as typeof fetch }),
|
||||||
|
).rejects.toBeInstanceOf(PairingCodeSpentError)
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps 410 → PairingCodeExpiredError', async () => {
|
||||||
|
const { dir, ks } = freshKs()
|
||||||
|
const fetchImpl = async () => jsonResponse(410, {})
|
||||||
|
await expect(
|
||||||
|
redeemPairingCode(ENROLL_URL, 'X', generateIdentity(), ks, { fetchImpl: fetchImpl as unknown as typeof fetch }),
|
||||||
|
).rejects.toBeInstanceOf(PairingCodeExpiredError)
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a schema-mismatched response (boundary validation)', async () => {
|
||||||
|
const { dir, ks } = freshKs()
|
||||||
|
const fetchImpl = async () => jsonResponse(200, { hostId: 'not-a-uuid', subdomain: 'x' })
|
||||||
|
await expect(
|
||||||
|
redeemPairingCode(ENROLL_URL, 'X', generateIdentity(), ks, { fetchImpl: fetchImpl as unknown as typeof fetch }),
|
||||||
|
).rejects.toBeInstanceOf(EnrollError)
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
72
agent/test/replaySeal.test.ts
Normal file
72
agent/test/replaySeal.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts'
|
||||||
|
import { createReplaySealer, type ReplayCrypto } from '../src/e2e/replaySeal.js'
|
||||||
|
|
||||||
|
/** Fake AEAD: key = tagged secret‖sessionId; ciphertext = plaintext XOR keyByte (marker hidden). */
|
||||||
|
function fakeCrypto(): ReplayCrypto & { derivations: ReplayKeyParams[] } {
|
||||||
|
const derivations: ReplayKeyParams[] = []
|
||||||
|
return {
|
||||||
|
derivations,
|
||||||
|
deriveContentKey(params: ReplayKeyParams): AeadKey {
|
||||||
|
derivations.push(params)
|
||||||
|
const tag = new TextEncoder().encode(`${Buffer.from(params.hostContentSecret).toString('hex')}:${params.sessionId}`)
|
||||||
|
return tag as unknown as AeadKey
|
||||||
|
},
|
||||||
|
sealReplayFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope {
|
||||||
|
const kb = (key as unknown as Uint8Array)[0] ?? 0x5a
|
||||||
|
const ciphertext = plaintext.map((b) => b ^ kb)
|
||||||
|
return { seq, nonce: new Uint8Array([Number(seq & 0xffn)]), ciphertext, tag: new Uint8Array([0xaa]) }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECRET = new Uint8Array([1, 2, 3, 4])
|
||||||
|
|
||||||
|
describe('createReplaySealer (T19, FIX 3)', () => {
|
||||||
|
it('derives K_content deterministically from (secret, sessionId, alg)', () => {
|
||||||
|
const c1 = fakeCrypto()
|
||||||
|
createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c1)
|
||||||
|
const c2 = fakeCrypto()
|
||||||
|
createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c2)
|
||||||
|
expect(c1.derivations[0]).toEqual(c2.derivations[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a different sessionId → a different key (per-session separation)', () => {
|
||||||
|
const c = fakeCrypto()
|
||||||
|
createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
|
||||||
|
createReplaySealer(SECRET, 'sess-2', 'aes-256-gcm', c)
|
||||||
|
expect(c.derivations[0]!.sessionId).not.toBe(c.derivations[1]!.sessionId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits a monotonic seq (INV13) and never leaks the plaintext marker (INV2)', () => {
|
||||||
|
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto())
|
||||||
|
const marker = new TextEncoder().encode('SECRET-MARKER')
|
||||||
|
const e0 = sealer.seal(marker)
|
||||||
|
const e1 = sealer.seal(marker)
|
||||||
|
expect(e0.seq).toBe(0n)
|
||||||
|
expect(e1.seq).toBe(1n)
|
||||||
|
expect(Buffer.from(e0.ciphertext).includes(Buffer.from(marker))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replay seal is DISTINCT from a live h2c seal for the same plaintext (FIX 3)', () => {
|
||||||
|
const replay = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto())
|
||||||
|
// model a live seal with a different key byte
|
||||||
|
const liveKey = new Uint8Array([0x11]) as unknown as AeadKey
|
||||||
|
const live = fakeCrypto().sealReplayFrame(liveKey, 0n, new Uint8Array([0x41, 0x42]))
|
||||||
|
const rep = replay.seal(new Uint8Array([0x41, 0x42]))
|
||||||
|
expect(Buffer.from(rep.ciphertext).equals(Buffer.from(live.ciphertext))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the hostContentSecret is never mutated', () => {
|
||||||
|
const secret = new Uint8Array([9, 9, 9])
|
||||||
|
const spy = vi.fn()
|
||||||
|
createReplaySealer(secret, 's', 'aes-256-gcm', {
|
||||||
|
deriveContentKey: (p) => {
|
||||||
|
spy(p.hostContentSecret)
|
||||||
|
return new Uint8Array([1]) as unknown as AeadKey
|
||||||
|
},
|
||||||
|
sealReplayFrame: (_k, seq, pt) => ({ seq, nonce: new Uint8Array(), ciphertext: pt, tag: new Uint8Array() }),
|
||||||
|
})
|
||||||
|
expect([...secret]).toEqual([9, 9, 9])
|
||||||
|
})
|
||||||
|
})
|
||||||
69
agent/test/revocation.test.ts
Normal file
69
agent/test/revocation.test.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { GOAWAY_REASON_TO_CODE } from 'relay-contracts'
|
||||||
|
import {
|
||||||
|
applyGoAway,
|
||||||
|
applyGoAwayCode,
|
||||||
|
classifyGoAway,
|
||||||
|
createRevocationState,
|
||||||
|
} from '../src/lifecycle/revocation.js'
|
||||||
|
|
||||||
|
describe('classifyGoAway (T14, frozen 3-value union)', () => {
|
||||||
|
it('maps operatorDrain and shutdown → drain, revoked → revoked', () => {
|
||||||
|
expect(classifyGoAway('operatorDrain')).toBe('drain')
|
||||||
|
expect(classifyGoAway('shutdown')).toBe('drain')
|
||||||
|
expect(classifyGoAway('revoked')).toBe('revoked')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createRevocationState (T14, INV12)', () => {
|
||||||
|
it('revoke tears down once and makes isRevoked() true (suppresses reconnect)', () => {
|
||||||
|
let teardowns = 0
|
||||||
|
const state = createRevocationState(() => {
|
||||||
|
teardowns += 1
|
||||||
|
})
|
||||||
|
expect(state.isRevoked()).toBe(false)
|
||||||
|
state.markRevoked('goaway-revoked')
|
||||||
|
expect(state.isRevoked()).toBe(true)
|
||||||
|
state.markRevoked('operator') // idempotent
|
||||||
|
expect(teardowns).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('applyGoAway / applyGoAwayCode (T14)', () => {
|
||||||
|
it('a revoked GOAWAY tears down; a drain GOAWAY does not', () => {
|
||||||
|
const revokeState = createRevocationState(() => {})
|
||||||
|
expect(applyGoAway('revoked', revokeState)).toBe('revoked')
|
||||||
|
expect(revokeState.isRevoked()).toBe(true)
|
||||||
|
|
||||||
|
const drainState = createRevocationState(() => {})
|
||||||
|
expect(applyGoAway('operatorDrain', drainState)).toBe('drain')
|
||||||
|
expect(applyGoAway('shutdown', drainState)).toBe('drain')
|
||||||
|
expect(drainState.isRevoked()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('decodes the frozen wire codes via decodeGoAwayReason', () => {
|
||||||
|
const state = createRevocationState(() => {})
|
||||||
|
expect(applyGoAwayCode(GOAWAY_REASON_TO_CODE.operatorDrain, state)).toBe('drain')
|
||||||
|
expect(applyGoAwayCode(GOAWAY_REASON_TO_CODE.revoked, state)).toBe('revoked')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('an unknown reason code FAILS CLOSED to revoked (INV12 safety)', () => {
|
||||||
|
const state = createRevocationState(() => {})
|
||||||
|
expect(applyGoAwayCode(99, state)).toBe('revoked')
|
||||||
|
expect(state.isRevoked()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cross-plan-drift guard: no bare integer reason literal in revocation.ts', () => {
|
||||||
|
const src = readFileSync(
|
||||||
|
join(import.meta.dirname, '..', 'src', 'lifecycle', 'revocation.ts'),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
// The reason mapping must come only from the frozen union/decoder — never a hardcoded
|
||||||
|
// `=== 2` / `reason: 2` style integer. (Comments are allowed to mention numbers.)
|
||||||
|
const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
|
||||||
|
expect(code).not.toMatch(/reason\s*[=:]\s*\d/)
|
||||||
|
expect(code).not.toMatch(/===\s*[123]\b/)
|
||||||
|
})
|
||||||
|
})
|
||||||
120
agent/test/rotation.test.ts
Normal file
120
agent/test/rotation.test.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||||
|
import { generateIdentity } from '../src/keys/identity.js'
|
||||||
|
import { openKeystore } from '../src/keys/keystore.js'
|
||||||
|
import {
|
||||||
|
computeRenewDelayMs,
|
||||||
|
createCertRotator,
|
||||||
|
renewCert,
|
||||||
|
renewalUrlFor,
|
||||||
|
} from '../src/certs/rotation.js'
|
||||||
|
import { FakeTimer } from './fixtures/fakes.js'
|
||||||
|
|
||||||
|
const CFG: AgentConfig = {
|
||||||
|
relayUrl: 'wss://relay/agent',
|
||||||
|
enrollUrl: 'https://example.com/enroll',
|
||||||
|
stateDir: '/tmp/x',
|
||||||
|
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||||
|
subdomain: 'host-42',
|
||||||
|
hostId: 'h-1',
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrolledKs() {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'wta-rot-'))
|
||||||
|
const ks = openKeystore(dir)
|
||||||
|
ks.saveIdentity(generateIdentity())
|
||||||
|
ks.saveCert('OLDCERT', 'OLDCA')
|
||||||
|
return { dir, ks }
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonRes(status: number, body: unknown): Response {
|
||||||
|
return { ok: status >= 200 && status < 300, status, json: async () => body } as unknown as Response
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('cert rotation math (T13)', () => {
|
||||||
|
it('derives P3 renewal URL from enrollUrl', () => {
|
||||||
|
expect(renewalUrlFor(CFG)).toBe('https://example.com/renew')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renews renewBeforeMs before expiry, clamped at 0', () => {
|
||||||
|
const now = new Date('2026-01-01T00:00:00Z')
|
||||||
|
const parse = () => new Date('2026-01-01T01:00:00Z') // expires in 1h
|
||||||
|
expect(computeRenewDelayMs('C', 5 * 60_000, now, parse)).toBe(55 * 60_000)
|
||||||
|
const parsePast = () => new Date('2025-01-01T00:00:00Z')
|
||||||
|
expect(computeRenewDelayMs('C', 5 * 60_000, now, parsePast)).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('renewCert (T13)', () => {
|
||||||
|
it('installs a fresh cert atomically on success (same key)', async () => {
|
||||||
|
const { dir, ks } = enrolledKs()
|
||||||
|
const before = ks.loadIdentity()!.publicKey
|
||||||
|
const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' }))
|
||||||
|
const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch)
|
||||||
|
expect(out).toBe('rotated')
|
||||||
|
expect(ks.loadCert()).toEqual({ certPem: 'NEWCERT', caChainPem: 'NEWCA' })
|
||||||
|
// pubkey unchanged — only the cert rotated
|
||||||
|
expect(Buffer.from(ks.loadIdentity()!.publicKey).equals(Buffer.from(before))).toBe(true)
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('403 → revoked (⇒ T14 teardown, INV12)', async () => {
|
||||||
|
const { dir, ks } = enrolledKs()
|
||||||
|
const fetchImpl = async () => jsonRes(403, {})
|
||||||
|
const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch)
|
||||||
|
expect(out).toBe('revoked')
|
||||||
|
expect(ks.loadCert()).toEqual({ certPem: 'OLDCERT', caChainPem: 'OLDCA' }) // untouched
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const flush = () => new Promise((r) => setImmediate(r))
|
||||||
|
|
||||||
|
describe('createCertRotator (T13)', () => {
|
||||||
|
it('fires onRevoked when the scheduled renewal returns 403', async () => {
|
||||||
|
const { dir, ks } = enrolledKs()
|
||||||
|
const timer = new FakeTimer()
|
||||||
|
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
||||||
|
timer,
|
||||||
|
renewBeforeMs: 1000,
|
||||||
|
fetchImpl: (async () => jsonRes(403, {})) as unknown as typeof fetch,
|
||||||
|
now: () => new Date(0),
|
||||||
|
parseCert: () => new Date(2000), // expires 2s after epoch → delay ~1000ms
|
||||||
|
})
|
||||||
|
let revoked = false
|
||||||
|
rotator.onRevoked(() => {
|
||||||
|
revoked = true
|
||||||
|
})
|
||||||
|
rotator.start()
|
||||||
|
timer.advance(1000) // scheduled renewal fires
|
||||||
|
await flush()
|
||||||
|
expect(revoked).toBe(true)
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rotates and reschedules on a successful renewal (seamless)', async () => {
|
||||||
|
const { dir, ks } = enrolledKs()
|
||||||
|
const timer = new FakeTimer()
|
||||||
|
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
||||||
|
timer,
|
||||||
|
renewBeforeMs: 1000,
|
||||||
|
fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' })) as unknown as typeof fetch,
|
||||||
|
now: () => new Date(0),
|
||||||
|
parseCert: () => new Date(2000),
|
||||||
|
})
|
||||||
|
let rotated = 0
|
||||||
|
rotator.onRotated(() => {
|
||||||
|
rotated += 1
|
||||||
|
})
|
||||||
|
rotator.start()
|
||||||
|
timer.advance(1000)
|
||||||
|
await flush()
|
||||||
|
expect(rotated).toBe(1)
|
||||||
|
expect(ks.loadCert()!.certPem).toBe('NEWCERT')
|
||||||
|
rotator.stop()
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
30
agent/test/scaffold.test.ts
Normal file
30
agent/test/scaffold.test.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import * as contracts from 'relay-contracts'
|
||||||
|
|
||||||
|
const pkg = JSON.parse(
|
||||||
|
readFileSync(join(import.meta.dirname, '..', 'package.json'), 'utf8'),
|
||||||
|
) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
|
||||||
|
|
||||||
|
describe('package scaffold', () => {
|
||||||
|
it('resolves the frozen relay-contracts surface', () => {
|
||||||
|
// §4.1 codec + §4.4 shapes + §4.5 pairing must all be importable.
|
||||||
|
expect(typeof contracts.encodeMuxFrame).toBe('function')
|
||||||
|
expect(typeof contracts.decodeMuxFrame).toBe('function')
|
||||||
|
expect(typeof contracts.decodeGoAwayReason).toBe('function')
|
||||||
|
expect(contracts.EnrollResultSchema).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('declares web-terminal-agent bin (INDEX §4.5 distribution)', () => {
|
||||||
|
expect(pkg.dependencies?.['relay-contracts']).toBe('file:../relay-contracts')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('INV11 tripwire: no ANSI/xterm/vt100 dependency', () => {
|
||||||
|
const allDeps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }
|
||||||
|
const forbidden = /xterm|ansi|vt100/i
|
||||||
|
for (const name of Object.keys(allDeps)) {
|
||||||
|
expect(forbidden.test(name), `forbidden terminal-parser dep: ${name}`).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
20
agent/test/seams.test.ts
Normal file
20
agent/test/seams.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
describe('transport seams (W0 cycle-breaker)', () => {
|
||||||
|
it('is a type-only module with no runtime side effects or transport deps', async () => {
|
||||||
|
const src = readFileSync(
|
||||||
|
join(import.meta.dirname, '..', 'src', 'transport', 'seams.ts'),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
// No runtime imports: the seam file must not pull ws/crypto/node runtime.
|
||||||
|
expect(src).not.toMatch(/from ['"]ws['"]/)
|
||||||
|
expect(src).not.toMatch(/from ['"]node:crypto['"]/)
|
||||||
|
// Importing it must not throw or emit anything.
|
||||||
|
const mod = await import('../src/transport/seams.js')
|
||||||
|
expect(mod).toBeDefined()
|
||||||
|
// Interfaces are erased at runtime, so the module has no runtime exports.
|
||||||
|
expect(Object.keys(mod)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
59
agent/test/security/tripwires.test.ts
Normal file
59
agent/test/security/tripwires.test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
const SRC = join(import.meta.dirname, '..', '..', 'src')
|
||||||
|
|
||||||
|
function walk(dir: string): string[] {
|
||||||
|
const out: string[] = []
|
||||||
|
for (const name of readdirSync(dir)) {
|
||||||
|
const path = join(dir, name)
|
||||||
|
if (statSync(path).isDirectory()) out.push(...walk(path))
|
||||||
|
else if (name.endsWith('.ts')) out.push(path)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = walk(SRC)
|
||||||
|
function code(path: string): string {
|
||||||
|
return readFileSync(path, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('agent security tripwires (T18, INDEX §3)', () => {
|
||||||
|
it('INV9: no console.log anywhere in src', () => {
|
||||||
|
for (const f of files) {
|
||||||
|
expect(code(f), `console.log in ${f}`).not.toMatch(/console\.log/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('INV11: no ANSI/xterm/vt100 import anywhere in src (byte-shuttle)', () => {
|
||||||
|
for (const f of files) {
|
||||||
|
expect(code(f), `terminal-parser import in ${f}`).not.toMatch(/from ['"][^'"]*(xterm|ansi|vt100)[^'"]*['"]/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('INV2 anti-MITM: hostEndpoint imports NO verifier from relay-e2e (FIX 6b)', () => {
|
||||||
|
const he = code(join(SRC, 'e2e', 'hostEndpoint.ts'))
|
||||||
|
expect(he).not.toMatch(/from ['"]relay-e2e['"]/)
|
||||||
|
expect(he).not.toMatch(/verifyDeviceAuthProof/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('INV12: revocation.ts uses the frozen decoder, no bare integer reason literal', () => {
|
||||||
|
const rev = code(join(SRC, 'lifecycle', 'revocation.ts'))
|
||||||
|
expect(rev).toMatch(/decodeGoAwayReason/)
|
||||||
|
expect(rev).not.toMatch(/reason\s*[=:]\s*\d/)
|
||||||
|
expect(rev).not.toMatch(/===\s*[123]\b/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('INV4: identity exposes no raw private-key byte export', () => {
|
||||||
|
const id = code(join(SRC, 'keys', 'identity.ts'))
|
||||||
|
expect(id).not.toMatch(/exportPrivateRaw|privateKeyBytes|rawPrivateKey/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every src module is well under the 800-line hard cap', () => {
|
||||||
|
for (const f of files) {
|
||||||
|
const lines = readFileSync(f, 'utf8').split('\n').length
|
||||||
|
expect(lines, `${f} is ${lines} lines`).toBeLessThan(800)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
140
agent/test/streamRouter.test.ts
Normal file
140
agent/test/streamRouter.test.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { decodeMuxFrame, encodeMuxFrame, type MuxOpen } from 'relay-contracts'
|
||||||
|
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||||
|
import { createLogger } from '../src/log/logger.js'
|
||||||
|
import { holdTunnel, dataHeader } from '../src/transport/tunnel.js'
|
||||||
|
import { createStreamRouter, identityTransform } from '../src/transport/streamRouter.js'
|
||||||
|
import { FakeWs } from './fixtures/fakes.js'
|
||||||
|
|
||||||
|
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 silentLogger = createLogger('error', () => {})
|
||||||
|
const flush = () => new Promise((r) => setImmediate(r))
|
||||||
|
|
||||||
|
function setup(dialImpl?: (path: string, origin: string) => Promise<FakeWs>) {
|
||||||
|
const upstream = new FakeWs()
|
||||||
|
const tunnel = holdTunnel(upstream)
|
||||||
|
const loopbacks: FakeWs[] = []
|
||||||
|
const dial = vi.fn(
|
||||||
|
dialImpl ??
|
||||||
|
(async () => {
|
||||||
|
const ws = new FakeWs()
|
||||||
|
loopbacks.push(ws)
|
||||||
|
return ws
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const router = createStreamRouter(CFG, tunnel, dial as never, identityTransform, silentLogger)
|
||||||
|
return { upstream, router, dial, loopbacks }
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastFrame(ws: FakeWs) {
|
||||||
|
return decodeMuxFrame(ws.sent.at(-1)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('StreamRouter (T8)', () => {
|
||||||
|
it('dials loopback with the request path and replayed Origin', async () => {
|
||||||
|
const { router, dial } = setup()
|
||||||
|
router.handleOpen(OPEN)
|
||||||
|
await flush()
|
||||||
|
expect(dial).toHaveBeenCalledWith('/term?join=abc', 'https://host-42.term.example.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('INV1 defense-in-depth: foreign subdomain is RST and NEVER dialed', async () => {
|
||||||
|
const { router, dial, upstream } = setup()
|
||||||
|
router.handleOpen({ ...OPEN, subdomain: 'host-999' })
|
||||||
|
await flush()
|
||||||
|
expect(dial).not.toHaveBeenCalled()
|
||||||
|
const f = lastFrame(upstream)
|
||||||
|
expect(f.header.type).toBe('close')
|
||||||
|
expect(f.header.rst).toBe(true)
|
||||||
|
expect(router.activeStreamCount()).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('splices loopback output → tunnel DATA', async () => {
|
||||||
|
const { router, upstream, loopbacks } = setup()
|
||||||
|
router.handleOpen(OPEN)
|
||||||
|
await flush()
|
||||||
|
const bytes = new Uint8Array([9, 8, 7])
|
||||||
|
loopbacks[0]!.emit('message', bytes)
|
||||||
|
const f = lastFrame(upstream)
|
||||||
|
expect(f.header.type).toBe('data')
|
||||||
|
expect(Buffer.from(f.payload).equals(Buffer.from(bytes))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forwards tunnel DATA → loopback socket (buffering pre-open)', async () => {
|
||||||
|
const { router, loopbacks } = setup()
|
||||||
|
router.handleOpen(OPEN)
|
||||||
|
router.handleData(5, new Uint8Array([1, 2])) // arrives before dial resolves → buffered
|
||||||
|
await flush()
|
||||||
|
router.handleData(5, new Uint8Array([3, 4]))
|
||||||
|
expect(loopbacks[0]!.sent.map((b) => [...b])).toEqual([[1, 2], [3, 4]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DATA on an unknown stream is RST (illegal transition)', () => {
|
||||||
|
const { router, upstream } = setup()
|
||||||
|
router.handleData(999, new Uint8Array([1]))
|
||||||
|
const f = lastFrame(upstream)
|
||||||
|
expect(f.header.type).toBe('close')
|
||||||
|
expect(f.header.rst).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('two concurrent streams get separate sockets (no cross-stream bleed)', async () => {
|
||||||
|
const { router, loopbacks } = setup()
|
||||||
|
router.handleOpen(OPEN)
|
||||||
|
router.handleOpen({ ...OPEN, streamId: 6 })
|
||||||
|
await flush()
|
||||||
|
router.handleData(5, new Uint8Array([0xaa]))
|
||||||
|
router.handleData(6, new Uint8Array([0xbb]))
|
||||||
|
expect(loopbacks[0]!.sent).toHaveLength(1)
|
||||||
|
expect(loopbacks[1]!.sent).toHaveLength(1)
|
||||||
|
expect([...loopbacks[0]!.sent[0]!]).toEqual([0xaa])
|
||||||
|
expect([...loopbacks[1]!.sent[0]!]).toEqual([0xbb])
|
||||||
|
expect(router.activeStreamCount()).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('CLOSE frees the loopback socket', async () => {
|
||||||
|
const { router, loopbacks } = setup()
|
||||||
|
router.handleOpen(OPEN)
|
||||||
|
await flush()
|
||||||
|
router.handleClose(5, false)
|
||||||
|
expect(loopbacks[0]!.closed).toBe(true)
|
||||||
|
expect(router.activeStreamCount()).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loopback connect-refused → RST upstream (typed, no swallow)', async () => {
|
||||||
|
const { router, upstream } = setup(async () => {
|
||||||
|
throw new Error('ECONNREFUSED')
|
||||||
|
})
|
||||||
|
router.handleOpen(OPEN)
|
||||||
|
await flush()
|
||||||
|
const f = lastFrame(upstream)
|
||||||
|
expect(f.header.type).toBe('close')
|
||||||
|
expect(f.header.rst).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('opaque splice sanity (INV11)', () => {
|
||||||
|
it('does not import any terminal parser (identity transform)', () => {
|
||||||
|
const bytes = new Uint8Array([0x1b, 0x5b, 0x41])
|
||||||
|
expect(identityTransform.outbound(1, bytes)).toBe(bytes)
|
||||||
|
// encode/decode round-trip stays byte-exact
|
||||||
|
const framed = encodeMuxFrame(dataHeader(1, bytes.length), bytes)
|
||||||
|
expect(Buffer.from(decodeMuxFrame(framed).payload).equals(Buffer.from(bytes))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
162
agent/test/tunnel.test.ts
Normal file
162
agent/test/tunnel.test.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
decodeMuxFrame,
|
||||||
|
encodeMuxFrame,
|
||||||
|
encodeOpen,
|
||||||
|
encodeGoaway,
|
||||||
|
type MuxFrameHeader,
|
||||||
|
type MuxOpen,
|
||||||
|
} from 'relay-contracts'
|
||||||
|
import { holdTunnel, dataHeader } from '../src/transport/tunnel.js'
|
||||||
|
import { FakeWs } from './fixtures/fakes.js'
|
||||||
|
|
||||||
|
const OPEN: MuxOpen = {
|
||||||
|
streamId: 5,
|
||||||
|
subdomain: 'host-42',
|
||||||
|
requestPath: '/term?join=abc',
|
||||||
|
originHeader: 'https://host-42.term.example.com',
|
||||||
|
remoteAddrHash: 'deadbeef',
|
||||||
|
capabilityTokenRef: 'jti-1',
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFrame(open: MuxOpen): Uint8Array {
|
||||||
|
const payload = encodeOpen(open)
|
||||||
|
const header: MuxFrameHeader = {
|
||||||
|
version: 1,
|
||||||
|
type: 'open',
|
||||||
|
fin: false,
|
||||||
|
rst: false,
|
||||||
|
streamId: open.streamId,
|
||||||
|
payloadLen: payload.length,
|
||||||
|
}
|
||||||
|
return encodeMuxFrame(header, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubHandlers() {
|
||||||
|
return {
|
||||||
|
handleOpen: vi.fn(),
|
||||||
|
handleData: vi.fn(),
|
||||||
|
handleClose: vi.fn(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const stubHb = { onPing: vi.fn(), onPong: vi.fn() }
|
||||||
|
|
||||||
|
describe('Tunnel (T7, §4.1)', () => {
|
||||||
|
it('round-trips a frame and yields decoded header+payload via onFrame', () => {
|
||||||
|
const ws = new FakeWs()
|
||||||
|
const tunnel = holdTunnel(ws)
|
||||||
|
const seen: Array<{ h: MuxFrameHeader; p: Uint8Array }> = []
|
||||||
|
tunnel.onFrame((h, p) => seen.push({ h, p }))
|
||||||
|
ws.emitMessage(openFrame(OPEN))
|
||||||
|
expect(seen).toHaveLength(1)
|
||||||
|
expect(seen[0]!.h.type).toBe('open')
|
||||||
|
expect(seen[0]!.h.streamId).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dispatches OPEN/DATA to the router', () => {
|
||||||
|
const ws = new FakeWs()
|
||||||
|
const tunnel = holdTunnel(ws)
|
||||||
|
const handlers = stubHandlers()
|
||||||
|
tunnel.dispatchTo(handlers, stubHb)
|
||||||
|
ws.emitMessage(openFrame(OPEN))
|
||||||
|
expect(handlers.handleOpen).toHaveBeenCalledOnce()
|
||||||
|
const data = new Uint8Array([1, 2, 3])
|
||||||
|
ws.emitMessage(encodeMuxFrame(dataHeader(5, 3), data))
|
||||||
|
expect(handlers.handleData).toHaveBeenCalledWith(5, expect.any(Uint8Array))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('INV11: DATA payload passes through opaque (no parsing)', () => {
|
||||||
|
const ws = new FakeWs()
|
||||||
|
const tunnel = holdTunnel(ws)
|
||||||
|
const handlers = stubHandlers()
|
||||||
|
tunnel.dispatchTo(handlers, stubHb)
|
||||||
|
const opaque = new Uint8Array([0x1b, 0x5b, 0x33, 0x31, 0x6d]) // looks like an ANSI seq
|
||||||
|
ws.emitMessage(encodeMuxFrame(dataHeader(7, opaque.length), opaque))
|
||||||
|
const forwarded = handlers.handleData.mock.calls[0]![1] as Uint8Array
|
||||||
|
expect(Buffer.from(forwarded).equals(Buffer.from(opaque))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drains after inbound GOAWAY: no new OPEN accepted', () => {
|
||||||
|
const ws = new FakeWs()
|
||||||
|
const tunnel = holdTunnel(ws)
|
||||||
|
const handlers = stubHandlers()
|
||||||
|
tunnel.dispatchTo(handlers, stubHb)
|
||||||
|
const goaway = encodeGoaway(0, 'operatorDrain')
|
||||||
|
ws.emitMessage(
|
||||||
|
encodeMuxFrame(
|
||||||
|
{ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: goaway.length },
|
||||||
|
goaway,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ws.emitMessage(openFrame({ ...OPEN, streamId: 9 }))
|
||||||
|
expect(handlers.handleOpen).not.toHaveBeenCalled()
|
||||||
|
// and it RST'd the refused stream
|
||||||
|
const last = decodeMuxFrame(ws.sent.at(-1)!)
|
||||||
|
expect(last.header.type).toBe('close')
|
||||||
|
expect(last.header.rst).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('malformed framing does not crash the tunnel', () => {
|
||||||
|
const ws = new FakeWs()
|
||||||
|
const tunnel = holdTunnel(ws)
|
||||||
|
tunnel.dispatchTo(stubHandlers(), stubHb)
|
||||||
|
expect(() => ws.emitMessage(new Uint8Array([0, 1, 2]))).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dispatches CLOSE→handleClose and PONG→heartbeat.onPong', () => {
|
||||||
|
const ws = new FakeWs()
|
||||||
|
const tunnel = holdTunnel(ws)
|
||||||
|
const handlers = stubHandlers()
|
||||||
|
const hb = { onPing: vi.fn(), onPong: vi.fn() }
|
||||||
|
tunnel.dispatchTo(handlers, hb)
|
||||||
|
ws.emitMessage(
|
||||||
|
encodeMuxFrame({ version: 1, type: 'close', fin: false, rst: true, streamId: 5, payloadLen: 0 }, new Uint8Array(0)),
|
||||||
|
)
|
||||||
|
expect(handlers.handleClose).toHaveBeenCalledWith(5, true)
|
||||||
|
ws.emitMessage(
|
||||||
|
encodeMuxFrame({ version: 1, type: 'pong', fin: false, rst: false, streamId: 0, payloadLen: 2 }, new Uint8Array([1, 2])),
|
||||||
|
)
|
||||||
|
expect(hb.onPong).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('send/goAway/sendRst/close emit well-formed frames', () => {
|
||||||
|
const ws = new FakeWs()
|
||||||
|
const tunnel = holdTunnel(ws)
|
||||||
|
tunnel.send(dataHeader(3, 2), new Uint8Array([9, 9]))
|
||||||
|
expect(decodeMuxFrame(ws.sent.at(-1)!).header.type).toBe('data')
|
||||||
|
tunnel.goAway(3, 'shutdown')
|
||||||
|
expect(decodeMuxFrame(ws.sent.at(-1)!).header.type).toBe('goaway')
|
||||||
|
tunnel.sendRst(3)
|
||||||
|
const rst = decodeMuxFrame(ws.sent.at(-1)!)
|
||||||
|
expect(rst.header.type).toBe('close')
|
||||||
|
expect(rst.header.rst).toBe(true)
|
||||||
|
tunnel.close()
|
||||||
|
expect(ws.closed).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('windowUpdate frames are dispatched without disturbing the stream handlers', () => {
|
||||||
|
const ws = new FakeWs()
|
||||||
|
const tunnel = holdTunnel(ws)
|
||||||
|
const handlers = stubHandlers()
|
||||||
|
tunnel.dispatchTo(handlers, stubHb)
|
||||||
|
ws.emitMessage(
|
||||||
|
encodeMuxFrame({ version: 1, type: 'windowUpdate', fin: false, rst: false, streamId: 1, payloadLen: 4 }, new Uint8Array([0, 0, 0, 5])),
|
||||||
|
)
|
||||||
|
expect(handlers.handleData).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('onGoAway surfaces the frozen reason', () => {
|
||||||
|
const ws = new FakeWs()
|
||||||
|
const tunnel = holdTunnel(ws)
|
||||||
|
const reasons: string[] = []
|
||||||
|
tunnel.onGoAway((r) => reasons.push(r))
|
||||||
|
const goaway = encodeGoaway(3, 'revoked')
|
||||||
|
ws.emitMessage(
|
||||||
|
encodeMuxFrame(
|
||||||
|
{ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: goaway.length },
|
||||||
|
goaway,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(reasons).toEqual(['revoked'])
|
||||||
|
})
|
||||||
|
})
|
||||||
23
agent/tsconfig.json
Normal file
23
agent/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "test/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
13
agent/vitest.config.ts
Normal file
13
agent/vitest.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['test/**/*.test.ts'],
|
||||||
|
environment: 'node',
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
exclude: ['**/*.d.ts', 'src/index.ts'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
26
control-plane/bin/provision.ts
Normal file
26
control-plane/bin/provision.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* T0 (v0.8) — manual provisioning CLI. `node bin/provision.ts <subdomain>` mints an account
|
||||||
|
* in the flat store and prints the raw agent/client tokens ONCE (they are never stored raw).
|
||||||
|
* This is the throwaway MVP admin path; T11 promotes it to an authenticated HTTP API.
|
||||||
|
*/
|
||||||
|
import { createFlatStore } from '../src/flat-store.js'
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const subdomain = process.argv[2]
|
||||||
|
if (subdomain === undefined || subdomain.trim() === '') {
|
||||||
|
process.stderr.write('usage: provision <subdomain>\n')
|
||||||
|
process.exitCode = 2
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const store = createFlatStore()
|
||||||
|
const { accountId, agentToken, clientToken } = await store.provisionFlat(subdomain.trim())
|
||||||
|
// Raw tokens printed ONCE for the operator to copy; they are not recoverable afterwards.
|
||||||
|
process.stdout.write(
|
||||||
|
JSON.stringify({ accountId, subdomain: subdomain.trim(), agentToken, clientToken }, null, 2) + '\n',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err: unknown) => {
|
||||||
|
process.stderr.write(`provision failed: ${err instanceof Error ? err.message : String(err)}\n`)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
9
control-plane/db/flat.sqlite.sql
Normal file
9
control-plane/db/flat.sqlite.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- T0 (v0.8 MVP) — flat account table (SQLite). Retired by CP1 Postgres registries (T2-T4).
|
||||||
|
-- Stores ONLY hashes of the agent/client tokens (INV5): raw tokens are returned once at mint
|
||||||
|
-- and never persisted. No immutable-record ceremony at this phase.
|
||||||
|
CREATE TABLE IF NOT EXISTS flat_accounts (
|
||||||
|
account_id TEXT PRIMARY KEY, -- uuid, unguessable
|
||||||
|
subdomain TEXT NOT NULL UNIQUE,
|
||||||
|
agent_token_hash TEXT NOT NULL, -- scrypt$salt$hash — never the raw token
|
||||||
|
client_token_hash TEXT NOT NULL
|
||||||
|
);
|
||||||
105
control-plane/db/migrations/0001_init.sql
Normal file
105
control-plane/db/migrations/0001_init.sql
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
-- T2 — control-plane schema. Transcribes INDEX §4.2 (accounts/hosts/sessions/pairing_codes)
|
||||||
|
-- VERBATIM, plus P3-owned tables (relay_nodes, metering_samples, audit_log) and the INV8 companion
|
||||||
|
-- status-version tables + current-pointer columns (OQ6 — pending INDEX promotion of §4.2).
|
||||||
|
--
|
||||||
|
-- Discipline: no bare UPDATE of a lifecycle/business field without a matching *_status_versions
|
||||||
|
-- insert in the SAME transaction. Liveness columns (last_seen, last_attach_at) are advisory
|
||||||
|
-- caches (INV7 — live truth is Redis) and are exempt from versioning.
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid()
|
||||||
|
|
||||||
|
-- ---- accounts (§4.2) --------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS accounts (
|
||||||
|
account_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- unguessable, never recycled
|
||||||
|
plan text NOT NULL, -- 'free'|'personal'|'pro'|'team'
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
status text NOT NULL DEFAULT 'active', -- 'active'|'suspended' (current pointer)
|
||||||
|
status_version int NOT NULL DEFAULT 1, -- INV8 monotonic pointer
|
||||||
|
CONSTRAINT accounts_plan_chk CHECK (plan IN ('free','personal','pro','team')),
|
||||||
|
CONSTRAINT accounts_status_chk CHECK (status IN ('active','suspended'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS account_status_versions (
|
||||||
|
account_id uuid NOT NULL REFERENCES accounts(account_id),
|
||||||
|
version int NOT NULL,
|
||||||
|
status text NOT NULL,
|
||||||
|
changed_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
changed_by text NOT NULL,
|
||||||
|
PRIMARY KEY (account_id, version)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---- hosts (§4.2) — ownership source of truth --------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS hosts (
|
||||||
|
host_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- unguessable, NEVER recycled (INV1)
|
||||||
|
account_id uuid NOT NULL REFERENCES accounts(account_id),
|
||||||
|
subdomain text NOT NULL UNIQUE, -- stable across reconnects
|
||||||
|
agent_pubkey bytea NOT NULL, -- Ed25519 PUBLIC key only (INV4)
|
||||||
|
enroll_fpr text NOT NULL, -- fingerprint of agent_pubkey (E2E TOFU)
|
||||||
|
status text NOT NULL DEFAULT 'offline', -- 'online'|'offline'|'draining'|'revoked'
|
||||||
|
last_seen timestamptz NOT NULL DEFAULT now(), -- advisory cache (NOT versioned, INV7)
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
revoked_at timestamptz NULL, -- set on revocation (INV12)
|
||||||
|
status_version int NOT NULL DEFAULT 1,
|
||||||
|
CONSTRAINT hosts_status_chk CHECK (status IN ('online','offline','draining','revoked'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS hosts_account_idx ON hosts(account_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS host_status_versions (
|
||||||
|
host_id uuid NOT NULL REFERENCES hosts(host_id),
|
||||||
|
version int NOT NULL,
|
||||||
|
status text NOT NULL,
|
||||||
|
revoked_at timestamptz NULL,
|
||||||
|
changed_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
changed_by text NOT NULL,
|
||||||
|
PRIMARY KEY (host_id, version)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---- sessions (§4.2) --------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
session_id uuid PRIMARY KEY, -- base-app sessionId, opaque to relay
|
||||||
|
host_id uuid NOT NULL REFERENCES hosts(host_id),
|
||||||
|
account_id uuid NOT NULL, -- DENORMALIZED for O(1) authz (INV3/INV6)
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
last_attach_at timestamptz NOT NULL DEFAULT now() -- advisory cache (NOT versioned)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---- pairing_codes (§4.2/§4.5) ----------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS pairing_codes (
|
||||||
|
code_hash text PRIMARY KEY, -- hash of single-use code; raw NEVER stored (INV5)
|
||||||
|
account_id uuid NOT NULL REFERENCES accounts(account_id),
|
||||||
|
expires_at timestamptz NOT NULL, -- short TTL
|
||||||
|
redeemed_at timestamptz NULL, -- single-use: non-null ⇒ spent
|
||||||
|
redeem_attempts int NOT NULL DEFAULT 0 -- code-scoped brute-force lockout (T8)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---- relay_nodes (P3-owned) -------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS relay_nodes (
|
||||||
|
node_id text PRIMARY KEY, -- SPIFFE-style id from mTLS cert subject
|
||||||
|
addr text NOT NULL,
|
||||||
|
status text NOT NULL DEFAULT 'active', -- 'active'|'draining'
|
||||||
|
last_seen timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT relay_nodes_status_chk CHECK (status IN ('active','draining'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---- metering_samples (P3-owned, append-only INV8) --------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS metering_samples (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
host_id uuid NOT NULL REFERENCES hosts(host_id),
|
||||||
|
account_id uuid NOT NULL, -- derived server-side from ownership (INV1)
|
||||||
|
node_id text NOT NULL, -- authenticated mTLS identity (INV3-analog)
|
||||||
|
concurrent_viewers int NOT NULL,
|
||||||
|
sampled_at timestamptz NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS metering_account_time_idx ON metering_samples(account_id, sampled_at);
|
||||||
|
|
||||||
|
-- ---- audit_log (P3-owned, append-only zero-payload INV10) -------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
action text NOT NULL,
|
||||||
|
principal_id text NOT NULL,
|
||||||
|
account_id text NOT NULL,
|
||||||
|
host_id text NULL,
|
||||||
|
ts timestamptz NOT NULL DEFAULT now(),
|
||||||
|
meta jsonb NOT NULL DEFAULT '{}'::jsonb -- metadata only; NO terminal payload (INV10)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS audit_account_time_idx ON audit_log(account_id, ts);
|
||||||
2307
control-plane/package-lock.json
generated
Normal file
2307
control-plane/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
control-plane/package.json
Normal file
31
control-plane/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "control-plane",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "P3 — Control Plane & Accounts for the rendezvous-relay service. Account/host/session registries (immutable, INV8), pairing issuance+redemption+BIND, subdomain assignment, Redis routing table, provisioning/metering/revocation APIs, registry-gated mTLS leaf signing (INV14), immutable zero-payload audit (INV10). Imports relay-contracts read-only. See docs/PLAN_RELAY_CONTROLPLANE.md.",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"main": "src/main.ts",
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"coverage": "vitest run --coverage"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"relay-contracts": "file:../relay-contracts",
|
||||||
|
"fastify": "^4.28.1",
|
||||||
|
"ioredis": "^5.4.1",
|
||||||
|
"pg": "^8.12.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.9.3",
|
||||||
|
"@types/pg": "^8.11.10",
|
||||||
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
77
control-plane/src/api/authz.ts
Normal file
77
control-plane/src/api/authz.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* T11 — admin-API principal derivation (INV3). `accountId` comes ONLY from the VERIFIED capability
|
||||||
|
* token (P5 owns the signing key + `verifyCapabilityToken`; we consume it as an injected verifier).
|
||||||
|
* Any body/query field named `account_id`/`tenant_id` is IGNORED for authz decisions. Deny-by-default:
|
||||||
|
* a missing/expired/foreign-`aud` token → 401.
|
||||||
|
*/
|
||||||
|
import { extractTokenFromSubprotocols, type CapabilityToken, type CapabilityRight } from 'relay-contracts'
|
||||||
|
|
||||||
|
export interface AdminPrincipal {
|
||||||
|
readonly accountId: string
|
||||||
|
readonly rights: readonly CapabilityRight[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthzError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: 401 | 403,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** P5's verifier, injected. Mirrors the frozen `verifyCapabilityToken` signature (§4.3). */
|
||||||
|
export interface CapabilityVerifier {
|
||||||
|
verify(raw: string, expectedAud: string, now: number): CapabilityToken
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal request shape we read for the bearer token (never a body account field). */
|
||||||
|
export interface AuthRequest {
|
||||||
|
readonly headers: Readonly<Record<string, string | string[] | undefined>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthzDeps {
|
||||||
|
readonly verifier: CapabilityVerifier
|
||||||
|
readonly expectedAud: string // admin audience the token must be scoped to (Host-confusion guard)
|
||||||
|
readonly now?: () => number
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRawToken(req: AuthRequest): string | null {
|
||||||
|
const auth = req.headers['authorization']
|
||||||
|
if (typeof auth === 'string' && auth.startsWith('Bearer ')) return auth.slice('Bearer '.length).trim()
|
||||||
|
// Also accept the WS-upgrade subprotocol transport (FIX 5), for parity with the relay edge.
|
||||||
|
const proto = req.headers['sec-websocket-protocol']
|
||||||
|
if (typeof proto === 'string') {
|
||||||
|
const values = proto.split(',').map((v) => v.trim())
|
||||||
|
return extractTokenFromSubprotocols(values)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Authorizer {
|
||||||
|
principalFromRequest(req: AuthRequest): 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) {
|
||||||
|
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())
|
||||||
|
} catch (err: unknown) {
|
||||||
|
throw new AuthzError(401, `invalid capability token: ${err instanceof Error ? err.message : 'rejected'}`)
|
||||||
|
}
|
||||||
|
// accountId derives ONLY from the token principal — never from a client field (INV3).
|
||||||
|
return { accountId: token.sub, rights: token.rights }
|
||||||
|
},
|
||||||
|
requireRight(principal, right) {
|
||||||
|
if (!principal.rights.includes(right)) {
|
||||||
|
throw new AuthzError(403, `token scope lacks required right: ${right}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
137
control-plane/src/api/provision.ts
Normal file
137
control-plane/src/api/provision.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* T11 — provisioning / deprovisioning HTTP routes. ALL admin routes are deny-by-default and scope
|
||||||
|
* every operation to `principal.accountId` (INV3/INV6). Host-targeting routes re-check
|
||||||
|
* `ownsHost` (INV1). No route resolves a host by a user-supplied address. `/enroll` is the one
|
||||||
|
* route NOT gated by a capability token (it IS the auth bootstrap) but is guarded by the single-use
|
||||||
|
* pairing code (T8). Every body is Zod-validated at the boundary; malformed → 400.
|
||||||
|
*/
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
|
||||||
|
import type { PlanTier } from 'relay-contracts'
|
||||||
|
import type { Authorizer, AdminPrincipal } from './authz.js'
|
||||||
|
import { AuthzError } from './authz.js'
|
||||||
|
import type { AccountRegistry } from '../registry/accounts.js'
|
||||||
|
import type { HostRegistry } from '../registry/hosts.js'
|
||||||
|
import type { PairingIssuer } from '../pairing/issue.js'
|
||||||
|
import type { PairingRedeemer } from '../pairing/redeem.js'
|
||||||
|
import { RedeemError } from '../pairing/redeem.js'
|
||||||
|
import type { Deprovisioner } from '../deprovision/deprovision.js'
|
||||||
|
|
||||||
|
export interface ProvisionDeps {
|
||||||
|
readonly authorizer: Authorizer
|
||||||
|
readonly accounts: AccountRegistry
|
||||||
|
readonly hosts: HostRegistry
|
||||||
|
readonly pairingIssuer: PairingIssuer
|
||||||
|
readonly redeemer: PairingRedeemer
|
||||||
|
readonly deprovisioner: Deprovisioner
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
function sendError(reply: FastifyReply, err: unknown): void {
|
||||||
|
if (err instanceof AuthzError) {
|
||||||
|
void reply.code(err.status).send({ error: err.message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (err instanceof RedeemError) {
|
||||||
|
const status = err.code === 'too_many_attempts' ? 429 : 400
|
||||||
|
void reply.code(status).send({ error: err.code })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void reply.code(400).send({ error: err instanceof Error ? err.message : 'bad request' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enforce that the path :id equals the authenticated principal's account (INV1). */
|
||||||
|
function assertOwnAccount(principal: AdminPrincipal, pathAccountId: string): void {
|
||||||
|
if (principal.accountId !== pathAccountId) {
|
||||||
|
throw new AuthzError(403, 'account scope mismatch') // never operate on another tenant
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
|
||||||
|
return async (app) => {
|
||||||
|
const principal = (req: FastifyRequest): AdminPrincipal =>
|
||||||
|
deps.authorizer.principalFromRequest({ headers: req.headers })
|
||||||
|
|
||||||
|
app.post('/accounts', async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const p = 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)
|
||||||
|
await reply.code(201).send(account)
|
||||||
|
} catch (err) {
|
||||||
|
sendError(reply, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/accounts/:id/pairing-codes', async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const p = principal(req)
|
||||||
|
deps.authorizer.requireRight(p, 'manage')
|
||||||
|
assertOwnAccount(p, (req.params as { id: string }).id)
|
||||||
|
const issued = await deps.pairingIssuer.issuePairingCode(p.accountId)
|
||||||
|
await reply.code(201).send(issued)
|
||||||
|
} catch (err) {
|
||||||
|
sendError(reply, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/accounts/:id/status', async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const p = principal(req)
|
||||||
|
deps.authorizer.requireRight(p, 'manage')
|
||||||
|
assertOwnAccount(p, (req.params as { id: string }).id)
|
||||||
|
const { status } = StatusSchema.parse(req.body)
|
||||||
|
const account = await deps.accounts.setAccountStatus(p.accountId, status)
|
||||||
|
await reply.send(account)
|
||||||
|
} catch (err) {
|
||||||
|
sendError(reply, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/accounts/:id/hosts', async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const p = 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') })))
|
||||||
|
} catch (err) {
|
||||||
|
sendError(reply, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/hosts/:hostId', async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const p = 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)
|
||||||
|
await reply.code(204).send()
|
||||||
|
} catch (err) {
|
||||||
|
sendError(reply, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/enroll', async (req, reply) => {
|
||||||
|
// NOT capability-token gated — guarded by the single-use pairing code (T8).
|
||||||
|
try {
|
||||||
|
const body = EnrollSchema.parse(req.body)
|
||||||
|
const result = await deps.redeemer.redeemPairingCode({
|
||||||
|
code: body.code,
|
||||||
|
agentPubkey: new Uint8Array(Buffer.from(body.agentPubkey, 'base64')),
|
||||||
|
csr: new Uint8Array(Buffer.from(body.csr, 'base64')),
|
||||||
|
})
|
||||||
|
await reply.code(201).send({ ...result, hostContentSecret: Buffer.from(result.hostContentSecret).toString('base64') })
|
||||||
|
} catch (err) {
|
||||||
|
sendError(reply, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
118
control-plane/src/audit/log.ts
Normal file
118
control-plane/src/audit/log.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* T14 — immutable, ZERO-PAYLOAD audit writer (INV10). Single funnel that P5 also imports for
|
||||||
|
* attach/manage/kill/revoke events. Append-only: no update/delete path is exposed. A payload
|
||||||
|
* guard rejects any `meta` value that could smuggle terminal output (length cap + control/ANSI
|
||||||
|
* bytes), so keystrokes/output/secrets can never land in `audit_log`.
|
||||||
|
*/
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { AuditStore, AuditRow } from '../store/ports.js'
|
||||||
|
|
||||||
|
export type AuditAction =
|
||||||
|
| 'account.create'
|
||||||
|
| 'account.suspend'
|
||||||
|
| 'host.bind'
|
||||||
|
| 'host.revoke'
|
||||||
|
| 'pairing.issue'
|
||||||
|
| 'pairing.redeem'
|
||||||
|
| 'subdomain.assign'
|
||||||
|
| 'node.drain'
|
||||||
|
| 'attach'
|
||||||
|
| 'manage'
|
||||||
|
| 'kill'
|
||||||
|
| 'revoke'
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
readonly action: AuditAction
|
||||||
|
readonly principalId: string
|
||||||
|
readonly accountId: string
|
||||||
|
readonly hostId: string | null
|
||||||
|
readonly ts: string
|
||||||
|
readonly meta: Readonly<Record<string, string>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Max length of a single metadata value — small enough that no terminal frame fits (INV10). */
|
||||||
|
export const MAX_META_VALUE_LEN = 256
|
||||||
|
/** Max number of metadata keys per entry. */
|
||||||
|
export const MAX_META_KEYS = 16
|
||||||
|
|
||||||
|
const AuditActionSchema = z.enum([
|
||||||
|
'account.create',
|
||||||
|
'account.suspend',
|
||||||
|
'host.bind',
|
||||||
|
'host.revoke',
|
||||||
|
'pairing.issue',
|
||||||
|
'pairing.redeem',
|
||||||
|
'subdomain.assign',
|
||||||
|
'node.drain',
|
||||||
|
'attach',
|
||||||
|
'manage',
|
||||||
|
'kill',
|
||||||
|
'revoke',
|
||||||
|
])
|
||||||
|
|
||||||
|
/** True if the string contains any C0 control byte (0x00–0x1f) — incl. ESC that begins ANSI. */
|
||||||
|
function hasControlBytes(v: string): boolean {
|
||||||
|
for (let i = 0; i < v.length; i++) {
|
||||||
|
if (v.charCodeAt(i) < 0x20) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const MetaValue = z
|
||||||
|
.string()
|
||||||
|
.max(MAX_META_VALUE_LEN, 'meta value exceeds zero-payload cap')
|
||||||
|
.refine((v) => !hasControlBytes(v), 'meta value contains control/ANSI bytes (possible terminal payload)')
|
||||||
|
|
||||||
|
const AuditEntrySchema = z
|
||||||
|
.object({
|
||||||
|
action: AuditActionSchema,
|
||||||
|
principalId: z.string().min(1),
|
||||||
|
accountId: z.string().min(1),
|
||||||
|
hostId: z.string().nullable(),
|
||||||
|
ts: z.string().datetime({ offset: true }),
|
||||||
|
meta: z.record(z.string(), MetaValue).refine((m) => Object.keys(m).length <= MAX_META_KEYS, {
|
||||||
|
message: 'too many meta keys',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export interface AuditWriter {
|
||||||
|
writeAuditEvent(entry: AuditEntry): Promise<void>
|
||||||
|
queryAudit(accountId: string, from: string, to: string): Promise<readonly AuditEntry[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuditLog(store: AuditStore): AuditWriter {
|
||||||
|
return {
|
||||||
|
async writeAuditEvent(entry) {
|
||||||
|
const parsed = AuditEntrySchema.safeParse(entry)
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error(`audit payload guard rejected entry: ${parsed.error.issues[0]?.message ?? 'invalid'}`)
|
||||||
|
}
|
||||||
|
const row: AuditRow = { ...parsed.data }
|
||||||
|
await store.append(row) // append-only, immutable
|
||||||
|
},
|
||||||
|
async queryAudit(accountId, from, to) {
|
||||||
|
const rows = await store.query(accountId, from, to)
|
||||||
|
return rows.map((r) => ({
|
||||||
|
action: r.action as AuditAction,
|
||||||
|
principalId: r.principalId,
|
||||||
|
accountId: r.accountId,
|
||||||
|
hostId: r.hostId,
|
||||||
|
ts: r.ts,
|
||||||
|
meta: r.meta,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No-op writer for tasks that treat audit as an optional injected dependency in v0.9. */
|
||||||
|
export function noopAuditWriter(): AuditWriter {
|
||||||
|
return {
|
||||||
|
async writeAuditEvent() {
|
||||||
|
/* intentionally does nothing */
|
||||||
|
},
|
||||||
|
async queryAudit() {
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
75
control-plane/src/boot/ca-wiring.ts
Normal file
75
control-plane/src/boot/ca-wiring.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* T15 — CA-key / secret-manager bootstrap + KMS-signer factory (§3.1). The intermediate
|
||||||
|
* leaf-signing key is a non-exportable KMS/HSM key: signing is a `sign()` CALL, the raw private
|
||||||
|
* key is NEVER loaded into control-plane memory. `buildCaSigner` FAILS FAST at startup (INV9) if
|
||||||
|
* the key ref is unresolvable OR its policy does not restrict `sign` to the control-plane
|
||||||
|
* service principal. Both T8 `signHostLeaf` and T15 `renewHostLeaf` call this shared primitive —
|
||||||
|
* neither loads a raw key.
|
||||||
|
*/
|
||||||
|
import { createPublicKey } from 'node:crypto'
|
||||||
|
import type { ControlPlaneEnv } from '../env.js'
|
||||||
|
import { ed25519Sign, generateEd25519, type KeyObject } from '../util/crypto.js'
|
||||||
|
|
||||||
|
/** Wraps KMS `sign()`; no raw key ever crosses this boundary. */
|
||||||
|
export interface CaSigner {
|
||||||
|
sign(tbsCert: Uint8Array): Promise<Uint8Array>
|
||||||
|
/** Public verifying key of the CA (for tests / chain assembly). */
|
||||||
|
readonly publicKeyRaw: Uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KMS resolver seam. Production resolves a real KMS/HSM key handle + policy; tests inject a fake.
|
||||||
|
* A resolver returns `{ canSign, policyRestrictedToServicePrincipal }` for the given ref, or throws
|
||||||
|
* if the ref cannot be resolved at all.
|
||||||
|
*/
|
||||||
|
export interface KmsResolver {
|
||||||
|
resolve(keyRef: string): Promise<{
|
||||||
|
readonly signer: CaSigner
|
||||||
|
readonly policyRestrictedToServicePrincipal: boolean
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the CA signer for the configured intermediate KMS key. Fail-fast (INV9, §3.1):
|
||||||
|
* - throws if the resolver cannot resolve the ref;
|
||||||
|
* - throws if the KMS key policy is broader than the control-plane service principal.
|
||||||
|
*/
|
||||||
|
export async function buildCaSigner(env: ControlPlaneEnv, resolver: KmsResolver): Promise<CaSigner> {
|
||||||
|
let resolved
|
||||||
|
try {
|
||||||
|
resolved = await resolver.resolve(env.caIntermediateKmsKeyRef)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Never log the ref VALUE — only that resolution failed (INV9).
|
||||||
|
throw new Error(
|
||||||
|
`CA intermediate KMS key could not be resolved: ${err instanceof Error ? err.message : 'unknown'}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!resolved.policyRestrictedToServicePrincipal) {
|
||||||
|
throw new Error(
|
||||||
|
'CA intermediate KMS key policy is broader than the control-plane service principal (§3.1) — refusing to boot',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return resolved.signer
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-process Ed25519 CA signer — TEST/DEV ONLY (clearly NOT a KMS; the plan mandates a real
|
||||||
|
* non-exportable KMS key in production, §3.1). Exposes the same `sign()` surface so callers are
|
||||||
|
* KMS-shaped and never touch a raw key path themselves.
|
||||||
|
*/
|
||||||
|
export function inProcessCaSigner(privateKey?: KeyObject): CaSigner {
|
||||||
|
const key = privateKey ?? generateEd25519().privateKey
|
||||||
|
const pub = derivePublicRaw(key)
|
||||||
|
return {
|
||||||
|
publicKeyRaw: pub,
|
||||||
|
async sign(tbsCert) {
|
||||||
|
return ed25519Sign(key, tbsCert)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function derivePublicRaw(privateKey: KeyObject): Uint8Array {
|
||||||
|
// Recover the raw 32-byte Ed25519 public key from the private KeyObject.
|
||||||
|
const spki = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }) as Buffer
|
||||||
|
return new Uint8Array(spki.subarray(spki.length - 32))
|
||||||
|
}
|
||||||
53
control-plane/src/ca/csr.ts
Normal file
53
control-plane/src/ca/csr.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
import { ed25519Sign, ed25519Verify, type KeyObject } from '../util/crypto.js'
|
||||||
|
|
||||||
|
const CSR_CHALLENGE = new TextEncoder().encode('relay-cp/csr-pop/v1|')
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CsrParts {
|
||||||
|
readonly embeddedPub: Uint8Array
|
||||||
|
readonly sig: Uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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) }
|
||||||
|
}
|
||||||
|
const message = concat(CSR_CHALLENGE, parts.embeddedPub)
|
||||||
|
const ok = ed25519Verify(parts.embeddedPub, message, parts.sig)
|
||||||
|
return { ok, embeddedPub: parts.embeddedPub }
|
||||||
|
}
|
||||||
|
|
||||||
|
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
||||||
|
const out = new Uint8Array(a.length + b.length)
|
||||||
|
out.set(a, 0)
|
||||||
|
out.set(b, a.length)
|
||||||
|
return out
|
||||||
|
}
|
||||||
6
control-plane/src/ca/fingerprint.ts
Normal file
6
control-plane/src/ca/fingerprint.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/** Enrollment fingerprint of an agent public key: SHA-256 hex (pinned by the browser for E2E TOFU, §4.4). */
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
|
||||||
|
export function fingerprint(agentPubkey: Uint8Array): string {
|
||||||
|
return createHash('sha256').update(agentPubkey).digest('hex')
|
||||||
|
}
|
||||||
55
control-plane/src/ca/rotate.ts
Normal file
55
control-plane/src/ca/rotate.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* T15 — CA leaf RENEWAL (INV14). Re-signs a short-TTL leaf for an already-bound, non-revoked host
|
||||||
|
* under the current intermediate. SAME guards as T8 `signHostLeaf`: (1) CSR proof-of-possession
|
||||||
|
* against the embedded pubkey; (2) embedded pubkey == the host's registered `agent_pubkey`;
|
||||||
|
* (3) host active/non-revoked. Any failure → same reject path. A drained/revoked host cannot renew
|
||||||
|
* (closes the INV12+INV14 loop). Signing = `CaSigner.sign()` (KMS, §3.1), never a raw key.
|
||||||
|
* Distinct file from T8 (`ca/sign.ts`) — shares only the KMS-signer primitive.
|
||||||
|
*/
|
||||||
|
import type { HostStore } from '../store/ports.js'
|
||||||
|
import type { CaSigner } from '../boot/ca-wiring.js'
|
||||||
|
import { verifyCsrPoP } from './csr.js'
|
||||||
|
import { LeafSignError } from './sign.js'
|
||||||
|
import { timingSafeEqualBytes, bytesToBase64 } from '../util/bytes.js'
|
||||||
|
|
||||||
|
export interface LeafRenewerDeps {
|
||||||
|
readonly hosts: HostStore
|
||||||
|
readonly signer: CaSigner
|
||||||
|
readonly caChainDer: readonly Uint8Array[]
|
||||||
|
readonly leafTtlSec?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeafRenewer {
|
||||||
|
renewHostLeaf(
|
||||||
|
hostId: string,
|
||||||
|
csr: Uint8Array,
|
||||||
|
): Promise<{ cert: Uint8Array; caChain: readonly Uint8Array[] }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_LEAF_TTL_SEC = 24 * 60 * 60
|
||||||
|
|
||||||
|
export function createLeafRenewer(deps: LeafRenewerDeps): LeafRenewer {
|
||||||
|
const ttl = deps.leafTtlSec ?? DEFAULT_LEAF_TTL_SEC
|
||||||
|
return {
|
||||||
|
async renewHostLeaf(hostId, csr) {
|
||||||
|
const host = await deps.hosts.get(hostId)
|
||||||
|
// A revoked/absent host cannot renew (INV12 + INV14).
|
||||||
|
if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered')
|
||||||
|
|
||||||
|
const pop = verifyCsrPoP(csr)
|
||||||
|
if (!pop.ok) throw new LeafSignError('csr_rejected')
|
||||||
|
// embedded pubkey must equal the host's REGISTERED pubkey (no key substitution on renewal).
|
||||||
|
if (!timingSafeEqualBytes(pop.embeddedPub, host.agentPubkey)) throw new LeafSignError('csr_rejected')
|
||||||
|
|
||||||
|
const notAfter = Math.floor(Date.now() / 1000) + ttl
|
||||||
|
const tbs = new TextEncoder().encode(
|
||||||
|
JSON.stringify({ v: 1, hostId, subjectSpki: bytesToBase64(host.agentPubkey), notAfter, renewed: true }),
|
||||||
|
)
|
||||||
|
const sig = await deps.signer.sign(tbs) // KMS sign(); no raw key
|
||||||
|
const cert = new TextEncoder().encode(
|
||||||
|
JSON.stringify({ tbs: bytesToBase64(tbs), sig: bytesToBase64(sig) }),
|
||||||
|
)
|
||||||
|
return { cert, caChain: deps.caChainDer }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
70
control-plane/src/ca/sign.ts
Normal file
70
control-plane/src/ca/sign.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* T8 — bind-time mTLS leaf signer, 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.
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
import type { HostStore } from '../store/ports.js'
|
||||||
|
import type { CaSigner } from '../boot/ca-wiring.js'
|
||||||
|
import { verifyCsrPoP } from './csr.js'
|
||||||
|
import { timingSafeEqualBytes, bytesToBase64 } from '../util/bytes.js'
|
||||||
|
|
||||||
|
export class LeafSignError extends Error {
|
||||||
|
constructor(public readonly code: 'csr_rejected' | 'not_registered') {
|
||||||
|
super('leaf signing refused') // uniform message — do not leak which check failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
agentPubkey: Uint8Array,
|
||||||
|
csr: Uint8Array,
|
||||||
|
): Promise<{ cert: Uint8Array; caChain: readonly Uint8Array[] }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_LEAF_TTL_SEC = 24 * 60 * 60
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
// Only now do we invoke KMS sign() over the to-be-signed leaf.
|
||||||
|
const notAfter = Math.floor(Date.now() / 1000) + ttl
|
||||||
|
const tbs = new TextEncoder().encode(
|
||||||
|
JSON.stringify({
|
||||||
|
v: 1,
|
||||||
|
hostId,
|
||||||
|
subjectSpki: bytesToBase64(agentPubkey), // subject pubkey == agentPubkey (assertable in tests)
|
||||||
|
notAfter,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const sig = await deps.signer.sign(tbs)
|
||||||
|
const cert = new TextEncoder().encode(
|
||||||
|
JSON.stringify({ tbs: bytesToBase64(tbs), sig: bytesToBase64(sig) }),
|
||||||
|
)
|
||||||
|
return { cert, caChain: deps.caChainDer }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
31
control-plane/src/db/pool.ts
Normal file
31
control-plane/src/db/pool.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* T2 — Postgres pool + typed `query` wrapper. PARAMETERIZED ONLY: the wrapper accepts
|
||||||
|
* `(sql, params)` and passes params to the driver separately — there is NO string-interpolation
|
||||||
|
* path, so SQL injection via value concatenation is structurally impossible (INV5-adjacent, §7).
|
||||||
|
*
|
||||||
|
* The client is injected (`Queryable`) so the wrapper is unit-testable without a live DB; the real
|
||||||
|
* `pg.Pool` is constructed by `createPgPool`. Applying the migrations + mapping the repository ports
|
||||||
|
* to SQL over this wrapper is the Testcontainers integration seam (PLAN §10).
|
||||||
|
*/
|
||||||
|
import pg from 'pg'
|
||||||
|
|
||||||
|
/** Minimal driver surface the wrapper needs — satisfied by `pg.Pool` and `pg.PoolClient`. */
|
||||||
|
export interface Queryable {
|
||||||
|
query(text: string, params: readonly unknown[]): Promise<{ rows: unknown[] }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueryFn = <T>(sql: string, params: readonly unknown[]) => Promise<readonly T[]>
|
||||||
|
|
||||||
|
/** Build a parameterized-only query function over any `Queryable`. */
|
||||||
|
export function createQuery(client: Queryable): QueryFn {
|
||||||
|
return async <T>(sql: string, params: readonly unknown[]): Promise<readonly T[]> => {
|
||||||
|
// Params ALWAYS travel separately from the SQL text — never interpolated into it.
|
||||||
|
const result = await client.query(sql, params)
|
||||||
|
return result.rows as T[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Construct a real Postgres connection pool (production). */
|
||||||
|
export function createPgPool(connectionString: string): pg.Pool & Queryable {
|
||||||
|
return new pg.Pool({ connectionString })
|
||||||
|
}
|
||||||
34
control-plane/src/deprovision/deprovision.ts
Normal file
34
control-plane/src/deprovision/deprovision.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* T11 — v0.9 MINIMAL deprovision (self-contained; superseded by T13 fast path in v0.10). Requires
|
||||||
|
* `ownsHost(principal.accountId, hostId)` (INV1/INV6), then `setHostStatus('revoked')` (T4 versioned
|
||||||
|
* snapshot) + `dropRoute` (T9). Best-effort tunnel drop only — NOT the INV12 within-seconds
|
||||||
|
* guarantee (that is T13's `revokeHost`). Depends on T4 + T9 ONLY; no dependency on v0.10.
|
||||||
|
*/
|
||||||
|
import type { HostRegistry } from '../registry/hosts.js'
|
||||||
|
import type { RoutingTable } from '../routing/table.js'
|
||||||
|
import type { AdminPrincipal } from '../api/authz.js'
|
||||||
|
import { AuthzError } from '../api/authz.js'
|
||||||
|
|
||||||
|
export interface Deprovisioner {
|
||||||
|
deprovisionHost(principal: AdminPrincipal, hostId: string): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeprovisionDeps {
|
||||||
|
readonly hosts: HostRegistry
|
||||||
|
readonly routing: RoutingTable
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDeprovisioner(deps: DeprovisionDeps): Deprovisioner {
|
||||||
|
return {
|
||||||
|
async deprovisionHost(principal, hostId) {
|
||||||
|
if (!(await deps.hosts.ownsHost(principal.accountId, hostId))) {
|
||||||
|
throw new AuthzError(403, 'not authorized for this host') // INV1: 403, never a 404 leak
|
||||||
|
}
|
||||||
|
const host = await deps.hosts.getHost(hostId)
|
||||||
|
if (host !== null && host.status !== 'revoked') {
|
||||||
|
await deps.hosts.setHostStatus(hostId, 'revoked')
|
||||||
|
}
|
||||||
|
await deps.routing.systemDropRoute(hostId)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
96
control-plane/src/env.ts
Normal file
96
control-plane/src/env.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* T1 — startup config/secret validation (INV9): secrets come from env/secret-manager,
|
||||||
|
* are Zod-validated at startup, fail fast, and are NEVER logged (only key NAMES on failure).
|
||||||
|
*
|
||||||
|
* The CA intermediate signing key is referenced by a KMS/HSM ref only — never inlined,
|
||||||
|
* never loaded raw into process memory (§3.1). The KMS key-policy check runs in
|
||||||
|
* `boot/ca-wiring.ts` (T15 `buildCaSigner`) with an injected resolver, because it needs a
|
||||||
|
* live KMS call; `loadEnv` validates only the presence/shape of the ref here.
|
||||||
|
*/
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { base64ToBytes } from './util/bytes.js'
|
||||||
|
|
||||||
|
/** Pairing-code TTL default: 10 minutes (§5 T7). */
|
||||||
|
export const DEFAULT_PAIRING_TTL_SEC = 600
|
||||||
|
/** Per-code redemption lockout threshold default (§5 T7/T8). */
|
||||||
|
export const DEFAULT_PAIRING_MAX_REDEEM_ATTEMPTS = 5
|
||||||
|
|
||||||
|
export interface ControlPlaneEnv {
|
||||||
|
readonly pgUrl: string
|
||||||
|
readonly redisUrl: string
|
||||||
|
/** P5 signing PUBLIC key, used to VERIFY admin capability tokens (INV3). */
|
||||||
|
readonly capabilitySignPubkey: Uint8Array
|
||||||
|
/** KMS/HSM key REF for the non-exportable intermediate leaf-signing key (§3.1). */
|
||||||
|
readonly caIntermediateKmsKeyRef: string
|
||||||
|
/** Intermediate cert (public) + chain up to the offline root. */
|
||||||
|
readonly caIntermediateCertPath: string
|
||||||
|
/** CA bundle used to VERIFY relay-node mTLS client certs (T9 node-auth). */
|
||||||
|
readonly nodeMtlsTrustBundlePath: string
|
||||||
|
/** 'term.<domain>' for subdomain assembly. */
|
||||||
|
readonly baseDomain: string
|
||||||
|
readonly heartbeatTtlSec: number
|
||||||
|
readonly pairingTtlSec: number
|
||||||
|
readonly pairingMaxRedeemAttempts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A positive integer parsed from an env string, or a default when unset/empty. */
|
||||||
|
const intWithDefault = (fallback: number) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v === undefined || v === '' ? String(fallback) : v))
|
||||||
|
.pipe(z.coerce.number().int().positive())
|
||||||
|
|
||||||
|
const requiredString = (name: string) =>
|
||||||
|
z.string({ required_error: `${name} is required` }).trim().min(1, `${name} must not be empty`)
|
||||||
|
|
||||||
|
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'),
|
||||||
|
NODE_MTLS_TRUST_BUNDLE_PATH: requiredString('NODE_MTLS_TRUST_BUNDLE_PATH'),
|
||||||
|
BASE_DOMAIN: requiredString('BASE_DOMAIN'),
|
||||||
|
HEARTBEAT_TTL_SEC: intWithDefault(15),
|
||||||
|
PAIRING_TTL_SEC: intWithDefault(DEFAULT_PAIRING_TTL_SEC),
|
||||||
|
PAIRING_MAX_REDEEM_ATTEMPTS: intWithDefault(DEFAULT_PAIRING_MAX_REDEEM_ATTEMPTS),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse + validate control-plane config. THROWS (fail-fast) listing every missing/invalid
|
||||||
|
* key by NAME. Never echoes secret VALUES (INV9).
|
||||||
|
*/
|
||||||
|
export function loadEnv(source: NodeJS.ProcessEnv): ControlPlaneEnv {
|
||||||
|
const parsed = EnvSchema.safeParse(source)
|
||||||
|
if (!parsed.success) {
|
||||||
|
const problems = parsed.error.issues
|
||||||
|
.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
|
||||||
|
.join('; ')
|
||||||
|
// Only key names + messages — never values (INV9).
|
||||||
|
throw new Error(`Invalid control-plane env: ${problems}`)
|
||||||
|
}
|
||||||
|
const e = parsed.data
|
||||||
|
let capabilitySignPubkey: Uint8Array
|
||||||
|
try {
|
||||||
|
capabilitySignPubkey = base64ToBytes(e.CAPABILITY_SIGN_PUBKEY_B64)
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid control-plane env: CAPABILITY_SIGN_PUBKEY_B64 is not valid base64')
|
||||||
|
}
|
||||||
|
if (capabilitySignPubkey.length !== 32) {
|
||||||
|
throw new Error('Invalid control-plane env: CAPABILITY_SIGN_PUBKEY_B64 must decode to 32 bytes (Ed25519)')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pgUrl: e.PG_URL,
|
||||||
|
redisUrl: e.REDIS_URL,
|
||||||
|
capabilitySignPubkey,
|
||||||
|
caIntermediateKmsKeyRef: e.CA_INTERMEDIATE_KMS_KEY_REF,
|
||||||
|
caIntermediateCertPath: e.CA_INTERMEDIATE_CERT_PATH,
|
||||||
|
nodeMtlsTrustBundlePath: e.NODE_MTLS_TRUST_BUNDLE_PATH,
|
||||||
|
baseDomain: e.BASE_DOMAIN,
|
||||||
|
heartbeatTtlSec: e.HEARTBEAT_TTL_SEC,
|
||||||
|
pairingTtlSec: e.PAIRING_TTL_SEC,
|
||||||
|
pairingMaxRedeemAttempts: e.PAIRING_MAX_REDEEM_ATTEMPTS,
|
||||||
|
}
|
||||||
|
}
|
||||||
71
control-plane/src/flat-store.ts
Normal file
71
control-plane/src/flat-store.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* T0 (v0.8 MVP) — flat account store + manual provisioning. Deliberately minimal: no
|
||||||
|
* immutable-record ceremony (that arrives in CP1/T3-T4, which RETIRE this shim). It exists
|
||||||
|
* only to gate the café demo. All INV5 guarantees (hashes-at-rest, constant-time compare,
|
||||||
|
* raw-secret-returned-once) are honoured and re-shipped properly in CP1.
|
||||||
|
*
|
||||||
|
* Backing store is injectable. The default is in-memory; production points it at the
|
||||||
|
* `db/flat.sqlite.sql` schema (SQLite) — an integration seam, not exercised in unit tests.
|
||||||
|
*/
|
||||||
|
import { randomUUID, randomBytes } from 'node:crypto'
|
||||||
|
import { hashSecret, verifySecret } from './util/hash.js'
|
||||||
|
|
||||||
|
export interface FlatAccount {
|
||||||
|
readonly accountId: string
|
||||||
|
readonly subdomain: string
|
||||||
|
readonly agentTokenHash: string
|
||||||
|
readonly clientTokenHash: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlatBackend {
|
||||||
|
insert(row: FlatAccount): Promise<void>
|
||||||
|
bySubdomain(subdomain: string): Promise<FlatAccount | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In-memory backend (default). SQLite backend is the production swap (see db/flat.sqlite.sql). */
|
||||||
|
export function inMemoryFlatBackend(): FlatBackend {
|
||||||
|
const rows = new Map<string, FlatAccount>()
|
||||||
|
return {
|
||||||
|
async insert(row) {
|
||||||
|
if (rows.has(row.subdomain)) throw new Error(`subdomain already provisioned: ${row.subdomain}`)
|
||||||
|
rows.set(row.subdomain, row)
|
||||||
|
},
|
||||||
|
async bySubdomain(subdomain) {
|
||||||
|
return rows.get(subdomain) ?? null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlatStore {
|
||||||
|
provisionFlat(subdomain: string): Promise<{ accountId: string; agentToken: string; clientToken: string }>
|
||||||
|
lookupBySubdomain(subdomain: string): Promise<FlatAccount | null>
|
||||||
|
verifyAgentToken(subdomain: string, raw: string): Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
const newToken = (): string => randomBytes(32).toString('base64url')
|
||||||
|
|
||||||
|
export function createFlatStore(backend: FlatBackend = inMemoryFlatBackend()): FlatStore {
|
||||||
|
return {
|
||||||
|
async provisionFlat(subdomain) {
|
||||||
|
const agentToken = newToken()
|
||||||
|
const clientToken = newToken()
|
||||||
|
const row: FlatAccount = {
|
||||||
|
accountId: randomUUID(),
|
||||||
|
subdomain,
|
||||||
|
agentTokenHash: hashSecret(agentToken), // hash at rest — raw discarded (INV5)
|
||||||
|
clientTokenHash: hashSecret(clientToken),
|
||||||
|
}
|
||||||
|
await backend.insert(row)
|
||||||
|
// Raw tokens returned exactly ONCE at mint time; never persisted.
|
||||||
|
return { accountId: row.accountId, agentToken, clientToken }
|
||||||
|
},
|
||||||
|
async lookupBySubdomain(subdomain) {
|
||||||
|
return backend.bySubdomain(subdomain)
|
||||||
|
},
|
||||||
|
async verifyAgentToken(subdomain, raw) {
|
||||||
|
const row = await backend.bySubdomain(subdomain)
|
||||||
|
if (row === null) return false
|
||||||
|
return verifySecret(raw, row.agentTokenHash) // constant-time compare of hashes
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
103
control-plane/src/main.ts
Normal file
103
control-plane/src/main.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* T11 — control-plane bootstrap. Wires env → stores → services → Fastify routes. The runnable
|
||||||
|
* v0.9-MVP default uses the in-memory stores; swapping in the Postgres/Redis-backed adapters
|
||||||
|
* (db/pool.ts + db/migrations + an ioredis client) is the Testcontainers integration step (PLAN §10).
|
||||||
|
*
|
||||||
|
* CROSS-PACKAGE INTEGRATION POINTS (injected, not hard-imported):
|
||||||
|
* - `CapabilityVerifier` — P5 owns `verifyCapabilityToken` + the signing key. Injected here; the
|
||||||
|
* default stub REFUSES all tokens (fail-closed) until P5 is wired.
|
||||||
|
* - `RevocationBus` — publish side over Redis `relay:revocations` (P1 subscribes). Default is the
|
||||||
|
* in-memory bus for single-process dev.
|
||||||
|
* - `KmsResolver` — P5/§3.1 KMS custody of the CA intermediate key. Default is an in-process
|
||||||
|
* Ed25519 signer (DEV ONLY — NOT a real KMS).
|
||||||
|
*/
|
||||||
|
import Fastify, { type FastifyInstance } from 'fastify'
|
||||||
|
import type { ControlPlaneEnv } from './env.js'
|
||||||
|
import { createMemoryStores } from './store/memory.js'
|
||||||
|
import type { Stores } from './store/ports.js'
|
||||||
|
import { createAuditLog } from './audit/log.js'
|
||||||
|
import { createAccountRegistry } from './registry/accounts.js'
|
||||||
|
import { createHostRegistry } from './registry/hosts.js'
|
||||||
|
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 { 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 type { RevocationBus } from 'relay-contracts'
|
||||||
|
|
||||||
|
export interface ControlPlaneOverrides {
|
||||||
|
readonly stores?: Stores
|
||||||
|
readonly verifier?: CapabilityVerifier
|
||||||
|
readonly bus?: RevocationBus & Partial<TestableRevocationBus>
|
||||||
|
readonly kmsResolver?: KmsResolver
|
||||||
|
readonly caChainDer?: readonly Uint8Array[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default fail-closed verifier — refuses everything until P5 is wired (INV6). */
|
||||||
|
const refuseAllVerifier: CapabilityVerifier = {
|
||||||
|
verify() {
|
||||||
|
throw new Error('capability verification not configured (P5 integration point)')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In-process KMS resolver — DEV ONLY. Production injects a real non-exportable KMS key (§3.1). */
|
||||||
|
function devKmsResolver(): KmsResolver {
|
||||||
|
const signer = inProcessCaSigner()
|
||||||
|
return {
|
||||||
|
async resolve() {
|
||||||
|
return { signer, policyRestrictedToServicePrincipal: true }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildControlPlane(
|
||||||
|
env: ControlPlaneEnv,
|
||||||
|
overrides: ControlPlaneOverrides = {},
|
||||||
|
): Promise<{ app: FastifyInstance; stores: Stores }> {
|
||||||
|
const stores = overrides.stores ?? createMemoryStores()
|
||||||
|
const bus: RevocationBus = overrides.bus ?? createInMemoryRevocationBus()
|
||||||
|
const caChainDer = overrides.caChainDer ?? []
|
||||||
|
|
||||||
|
const audit = createAuditLog(stores.audit)
|
||||||
|
const accounts = createAccountRegistry({ accounts: stores.accounts, audit })
|
||||||
|
const hosts = createHostRegistry({ hosts: stores.hosts, audit })
|
||||||
|
createSessionRegistry({ sessions: stores.sessions, hosts: stores.hosts })
|
||||||
|
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 pairingIssuer = createPairingIssuer({ pairing: stores.pairing, pairingTtlSec: env.pairingTtlSec, audit })
|
||||||
|
const redeemer = createPairingRedeemer({
|
||||||
|
pairing: stores.pairing,
|
||||||
|
hosts,
|
||||||
|
subdomains,
|
||||||
|
leafSigner,
|
||||||
|
pairingMaxRedeemAttempts: env.pairingMaxRedeemAttempts,
|
||||||
|
audit,
|
||||||
|
})
|
||||||
|
|
||||||
|
const routing = createRoutingTable({
|
||||||
|
routes: stores.routes,
|
||||||
|
nodeStatus: async (nodeId) => (await stores.nodes.get(nodeId))?.status ?? null,
|
||||||
|
})
|
||||||
|
createMeteringCollector({ metering: stores.metering, hosts })
|
||||||
|
const deprovisioner = createDeprovisioner({ hosts, routing })
|
||||||
|
void bus // reserved for the node-coordinator / revoker wiring (drain + revoke publishers)
|
||||||
|
|
||||||
|
const authorizer = createAuthorizer({
|
||||||
|
verifier: overrides.verifier ?? refuseAllVerifier,
|
||||||
|
expectedAud: env.baseDomain,
|
||||||
|
})
|
||||||
|
|
||||||
|
const app = Fastify({ logger: false })
|
||||||
|
await app.register(buildRouter({ authorizer, accounts, hosts, pairingIssuer, redeemer, deprovisioner }))
|
||||||
|
return { app, stores }
|
||||||
|
}
|
||||||
86
control-plane/src/metering/collect.ts
Normal file
86
control-plane/src/metering/collect.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* T12 — billing-metering hooks (EXPLORE §6): meter PAIRED HOSTS + CONCURRENT VIEWERS (not
|
||||||
|
* bandwidth). The sample's `nodeId` is the caller's authenticated mTLS identity (INV3-analog) and
|
||||||
|
* its `accountId` is re-derived SERVER-SIDE from the host registry (INV1) — a node-supplied account
|
||||||
|
* is never trusted. Samples are append-only immutable rows (INV8); zero terminal payload (INV10).
|
||||||
|
*/
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { MeteringSampleRow } from '../model/records.js'
|
||||||
|
import type { MeteringStore } from '../store/ports.js'
|
||||||
|
import type { HostRegistry } from '../registry/hosts.js'
|
||||||
|
import type { NodeIdentity } from '../node-auth/identity.js'
|
||||||
|
|
||||||
|
export interface MeteringSample {
|
||||||
|
readonly hostId: string
|
||||||
|
readonly concurrentViewers: number
|
||||||
|
readonly sampledAt: string
|
||||||
|
// NO client/node-supplied accountId or nodeId — both are derived server-side.
|
||||||
|
}
|
||||||
|
|
||||||
|
const MeteringSampleSchema = z
|
||||||
|
.object({
|
||||||
|
hostId: z.string().uuid(),
|
||||||
|
concurrentViewers: z.number().int().nonnegative(),
|
||||||
|
sampledAt: z.string().datetime({ offset: true }),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export class MeteringError extends Error {}
|
||||||
|
|
||||||
|
export interface UsageRollup {
|
||||||
|
readonly pairedHostPeak: number
|
||||||
|
readonly viewerPeak: number
|
||||||
|
readonly viewerHours: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeteringCollector {
|
||||||
|
ingestSample(caller: NodeIdentity, sample: MeteringSample): Promise<void>
|
||||||
|
pairedHostCount(accountId: string): Promise<number>
|
||||||
|
rollupUsage(accountId: string, from: string, to: string): Promise<UsageRollup>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeteringDeps {
|
||||||
|
readonly metering: MeteringStore
|
||||||
|
readonly hosts: HostRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMeteringCollector(deps: MeteringDeps): MeteringCollector {
|
||||||
|
return {
|
||||||
|
async ingestSample(caller, sample) {
|
||||||
|
const parsed = MeteringSampleSchema.safeParse(sample)
|
||||||
|
if (!parsed.success) throw new MeteringError(`invalid metering sample: ${parsed.error.issues[0]?.message}`)
|
||||||
|
const host = await deps.hosts.getHost(parsed.data.hostId)
|
||||||
|
// Attribution derived from ownership — a hostId the caller can't substantiate is rejected (INV1).
|
||||||
|
if (host === null) throw new MeteringError('unknown hostId — cannot attribute usage')
|
||||||
|
const row: MeteringSampleRow = {
|
||||||
|
hostId: parsed.data.hostId,
|
||||||
|
accountId: host.accountId, // server-derived, never node-asserted
|
||||||
|
nodeId: caller.nodeId, // authenticated identity, never a body field
|
||||||
|
concurrentViewers: parsed.data.concurrentViewers,
|
||||||
|
sampledAt: parsed.data.sampledAt,
|
||||||
|
}
|
||||||
|
await deps.metering.append(row) // append-only (INV8)
|
||||||
|
},
|
||||||
|
async pairedHostCount(accountId) {
|
||||||
|
const hosts = await deps.hosts.listHosts(accountId)
|
||||||
|
return hosts.filter((h) => h.status !== 'revoked').length
|
||||||
|
},
|
||||||
|
async rollupUsage(accountId, from, to) {
|
||||||
|
const samples = [...(await deps.metering.query(accountId, from, to))].sort(
|
||||||
|
(a, b) => Date.parse(a.sampledAt) - Date.parse(b.sampledAt),
|
||||||
|
)
|
||||||
|
const viewerPeak = samples.reduce((m, s) => Math.max(m, s.concurrentViewers), 0)
|
||||||
|
const distinctHosts = new Set(samples.map((s) => s.hostId))
|
||||||
|
// Step-function integral of concurrent viewers over the window → viewer-hours.
|
||||||
|
const toMs = Date.parse(to)
|
||||||
|
let viewerHours = 0
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
const cur = samples[i] as MeteringSampleRow
|
||||||
|
const nextMs = i + 1 < samples.length ? Date.parse((samples[i + 1] as MeteringSampleRow).sampledAt) : toMs
|
||||||
|
const spanHours = Math.max(0, nextMs - Date.parse(cur.sampledAt)) / 3_600_000
|
||||||
|
viewerHours += cur.concurrentViewers * spanHours
|
||||||
|
}
|
||||||
|
return { pairedHostPeak: distinctHosts.size, viewerPeak, viewerHours }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
49
control-plane/src/model/records.ts
Normal file
49
control-plane/src/model/records.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* T2 — control-plane record types. The frozen §4.2/§4.5 shapes are RE-EXPORTED from
|
||||||
|
* relay-contracts, never redefined locally (INDEX §2 rule 1).
|
||||||
|
*
|
||||||
|
* OQ1 RESOLUTION: `AccountRecord`/`SessionRecord`/`PairingCodeRecord` were promoted into
|
||||||
|
* relay-contracts (`model/account.ts`, `pairing/enroll.ts`), so this module re-exports them
|
||||||
|
* instead of mirroring the SQL locally. The only P3-local additions are the INV8 companion
|
||||||
|
* status-version ROW types (OQ6 — pending INDEX promotion of the `*_status_versions` tables).
|
||||||
|
*/
|
||||||
|
export type {
|
||||||
|
AccountRecord,
|
||||||
|
AccountStatus,
|
||||||
|
SessionRecord,
|
||||||
|
HostRecord,
|
||||||
|
HostStatus,
|
||||||
|
PlanTier,
|
||||||
|
RouteEntry,
|
||||||
|
} from 'relay-contracts'
|
||||||
|
export type { PairingCodeRecord, EnrollResult } from 'relay-contracts'
|
||||||
|
|
||||||
|
import type { AccountStatus, HostStatus } from 'relay-contracts'
|
||||||
|
|
||||||
|
/** Append-only companion row for `accounts.status` versioning (INV8, OQ6). */
|
||||||
|
export interface AccountStatusVersionRow {
|
||||||
|
readonly accountId: string
|
||||||
|
readonly version: number
|
||||||
|
readonly status: AccountStatus
|
||||||
|
readonly changedAt: string
|
||||||
|
readonly changedBy: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append-only companion row for `hosts.status`/`revoked_at` versioning (INV8, OQ6). */
|
||||||
|
export interface HostStatusVersionRow {
|
||||||
|
readonly hostId: string
|
||||||
|
readonly version: number
|
||||||
|
readonly status: HostStatus
|
||||||
|
readonly revokedAt: string | null
|
||||||
|
readonly changedAt: string
|
||||||
|
readonly changedBy: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Immutable metering sample row (append-only, INV8). */
|
||||||
|
export interface MeteringSampleRow {
|
||||||
|
readonly hostId: string
|
||||||
|
readonly accountId: string
|
||||||
|
readonly nodeId: string
|
||||||
|
readonly concurrentViewers: number
|
||||||
|
readonly sampledAt: string
|
||||||
|
}
|
||||||
56
control-plane/src/node-auth/identity.ts
Normal file
56
control-plane/src/node-auth/identity.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* T9 — relay-node ↔ control-plane trust boundary (the node analog of INV3). `nodeId` is derived
|
||||||
|
* from the VERIFIED relay-node mTLS client-cert subject — NEVER from a body/query/header field.
|
||||||
|
* A request that carries a `nodeId` payload has it IGNORED for identity. Non-mutually-authenticated
|
||||||
|
* connections throw 401.
|
||||||
|
*
|
||||||
|
* OQ5 (open): who ISSUES/ROTATES the node service certs + the SVID format the CP pins
|
||||||
|
* (`nodeMtlsTrustBundlePath`) is a P5/P1 boundary. Here we consume an already-verified peer cert.
|
||||||
|
*/
|
||||||
|
export interface NodeIdentity {
|
||||||
|
readonly nodeId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NodeAuthError extends Error {
|
||||||
|
readonly status = 401
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shape we need from a verified TLS peer certificate (subset of Node's PeerCertificate). */
|
||||||
|
export interface VerifiedPeerCert {
|
||||||
|
readonly authorized: boolean // TLS stack verified the client cert against the trust bundle
|
||||||
|
readonly subjectCommonName: string | null // SPIFFE-style node id in the cert subject CN / SAN URI
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure derivation — the request wrapper below feeds it the extracted peer cert. */
|
||||||
|
export function deriveNodeIdentity(cert: VerifiedPeerCert | null): NodeIdentity {
|
||||||
|
if (cert === null || !cert.authorized) {
|
||||||
|
throw new NodeAuthError('relay-node connection is not mutually authenticated')
|
||||||
|
}
|
||||||
|
const cn = cert.subjectCommonName
|
||||||
|
if (cn === null || cn.trim() === '') {
|
||||||
|
throw new NodeAuthError('relay-node client cert has no subject identity')
|
||||||
|
}
|
||||||
|
return { nodeId: cn }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal request shape carrying a TLS socket (Fastify/Node). */
|
||||||
|
export interface RequestWithTls {
|
||||||
|
readonly socket: {
|
||||||
|
authorized?: boolean
|
||||||
|
getPeerCertificate?: () => { subject?: { CN?: string } } | undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the verified node identity from a live request's TLS session. INTEGRATION SEAM: the
|
||||||
|
* exact SAN/URI SVID parsing is finalized with OQ5; here we read authorized + subject CN.
|
||||||
|
*/
|
||||||
|
export function nodeIdentityFromRequest(req: RequestWithTls): NodeIdentity {
|
||||||
|
const authorized = req.socket.authorized === true
|
||||||
|
const peer = req.socket.getPeerCertificate?.()
|
||||||
|
const cn = peer?.subject?.CN ?? null
|
||||||
|
return deriveNodeIdentity({ authorized, subjectCommonName: cn })
|
||||||
|
}
|
||||||
41
control-plane/src/pairing/code.ts
Normal file
41
control-plane/src/pairing/code.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Pairing-code format (Finding-4). The code is the SOLE credential gating `/enroll`, so it is
|
||||||
|
* 26 Crockford-base32 characters = 130 bits of real entropy (NOT the ~32-bit `ABCD-1234` shape,
|
||||||
|
* which is rejected). Displayed grouped for humans as 6 dash-separated groups; a paste/QR-first
|
||||||
|
* credential, not a type-from-memory PIN.
|
||||||
|
*/
|
||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
|
||||||
|
/** Crockford base32 alphabet (excludes I L O U to avoid confusion). */
|
||||||
|
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
|
||||||
|
export const PAIRING_CODE_LEN = 26 // 26 * 5 bits = 130 bits
|
||||||
|
export const PAIRING_CODE_BITS = PAIRING_CODE_LEN * 5
|
||||||
|
|
||||||
|
/** Generate a canonical (undashed, uppercase) 26-char Crockford code. */
|
||||||
|
export function generatePairingCode(): string {
|
||||||
|
// Draw enough random bytes, map 5 bits per char.
|
||||||
|
const bytes = randomBytes(PAIRING_CODE_LEN)
|
||||||
|
let out = ''
|
||||||
|
for (let i = 0; i < PAIRING_CODE_LEN; i++) {
|
||||||
|
out += CROCKFORD[(bytes[i] as number) & 0x1f]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Group the canonical code for display: XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-X. */
|
||||||
|
export function formatPairingCode(canonical: string): string {
|
||||||
|
return (canonical.match(/.{1,5}/g) ?? []).join('-')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a user-presented code back to canonical form: uppercase, strip dashes/spaces, map
|
||||||
|
* Crockford-confusable characters (I/L→1, O→0). Redemption hashes THIS canonical form.
|
||||||
|
*/
|
||||||
|
export function normalizePairingCode(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[\s-]/g, '')
|
||||||
|
.replace(/[IL]/g, '1')
|
||||||
|
.replace(/O/g, '0')
|
||||||
|
.replace(/U/g, 'V')
|
||||||
|
}
|
||||||
52
control-plane/src/pairing/issue.ts
Normal file
52
control-plane/src/pairing/issue.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* T7 — pairing-code issuance (INDEX §4.5 step 1). Mints a single-use short-TTL code; stores
|
||||||
|
* `{ code_hash, account_id, expires_at, redeem_attempts:0 }` — the RAW code is never stored
|
||||||
|
* (INV5). `accountId` comes from the authenticated principal (INV3), never a request field.
|
||||||
|
*/
|
||||||
|
import type { PairingCodeRecord } from '../model/records.js'
|
||||||
|
import type { PairingStore } from '../store/ports.js'
|
||||||
|
import type { AuditWriter } from '../audit/log.js'
|
||||||
|
import { noopAuditWriter } from '../audit/log.js'
|
||||||
|
import { generatePairingCode, formatPairingCode } from './code.js'
|
||||||
|
import { sha256Hex } from '../util/hash.js'
|
||||||
|
import { nowIso } from '../util/ids.js'
|
||||||
|
|
||||||
|
export interface IssuedPairing {
|
||||||
|
readonly code: string // display-grouped, returned ONCE
|
||||||
|
readonly expiresAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairingIssuer {
|
||||||
|
issuePairingCode(accountId: string): Promise<IssuedPairing>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairingIssuerDeps {
|
||||||
|
readonly pairing: PairingStore
|
||||||
|
readonly pairingTtlSec: number
|
||||||
|
readonly audit?: AuditWriter
|
||||||
|
readonly actor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPairingIssuer(deps: PairingIssuerDeps): PairingIssuer {
|
||||||
|
const audit = deps.audit ?? noopAuditWriter()
|
||||||
|
const actor = deps.actor ?? 'system'
|
||||||
|
return {
|
||||||
|
async issuePairingCode(accountId) {
|
||||||
|
const canonical = generatePairingCode()
|
||||||
|
const codeHash = sha256Hex(canonical) // deterministic hash of a 130-bit input (INV5)
|
||||||
|
const expiresAt = new Date(Date.now() + deps.pairingTtlSec * 1000).toISOString()
|
||||||
|
const record: PairingCodeRecord = { codeHash, accountId, expiresAt, redeemedAt: null }
|
||||||
|
await deps.pairing.insert(record)
|
||||||
|
await audit.writeAuditEvent({
|
||||||
|
action: 'pairing.issue',
|
||||||
|
principalId: actor,
|
||||||
|
accountId,
|
||||||
|
hostId: null,
|
||||||
|
ts: nowIso(),
|
||||||
|
meta: { expiresAt },
|
||||||
|
})
|
||||||
|
// Raw code returned ONCE, display-grouped; never persisted.
|
||||||
|
return { code: formatPairingCode(canonical), expiresAt }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
131
control-plane/src/pairing/redeem.ts
Normal file
131
control-plane/src/pairing/redeem.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* T8 — pairing-code redemption + BIND + bind-time cert + content-secret mint (INDEX §4.5 3–5).
|
||||||
|
* ATOMIC single-use: `casRedeem` compare-and-sets `redeemed_at` so exactly one concurrent caller
|
||||||
|
* wins (no double-spend). A CODE-SCOPED lockout (Finding-4) counts failed redemptions per
|
||||||
|
* `code_hash` and locks after `pairingMaxRedeemAttempts` — independent of P5's per-tenant limiter.
|
||||||
|
* `accountId` is taken from the pairing ROW (INV3), never the agent's request. Returns the FROZEN
|
||||||
|
* `EnrollResult` (imported from relay-contracts, never redefined).
|
||||||
|
*/
|
||||||
|
import type { EnrollResult } from 'relay-contracts'
|
||||||
|
import type { PairingStore } from '../store/ports.js'
|
||||||
|
import type { HostRegistry } from '../registry/hosts.js'
|
||||||
|
import type { SubdomainAssigner } from '../subdomain/assign.js'
|
||||||
|
import type { LeafSigner } from '../ca/sign.js'
|
||||||
|
import type { AuditWriter } from '../audit/log.js'
|
||||||
|
import { noopAuditWriter } from '../audit/log.js'
|
||||||
|
import { verifyCsrPoP } from '../ca/csr.js'
|
||||||
|
import { fingerprint } from '../ca/fingerprint.js'
|
||||||
|
import { sha256Hex } from '../util/hash.js'
|
||||||
|
import { timingSafeEqualBytes, bytesToBase64 } from '../util/bytes.js'
|
||||||
|
import { sealToX25519 } from '../util/crypto.js'
|
||||||
|
import { normalizePairingCode } from './code.js'
|
||||||
|
import { nowIso } from '../util/ids.js'
|
||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
|
||||||
|
export type RedeemErrorCode =
|
||||||
|
| 'unknown'
|
||||||
|
| 'expired'
|
||||||
|
| 'already_redeemed'
|
||||||
|
| 'too_many_attempts'
|
||||||
|
| 'bad_csr'
|
||||||
|
|
||||||
|
export class RedeemError extends Error {
|
||||||
|
constructor(public readonly code: RedeemErrorCode) {
|
||||||
|
super(`pairing redemption refused: ${code}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedeemInput {
|
||||||
|
readonly code: string
|
||||||
|
readonly agentPubkey: Uint8Array
|
||||||
|
readonly csr: Uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIX 3 — mint + WRAP the host-scoped content secret at BIND. A per-host 32-byte CSPRNG secret
|
||||||
|
* is sealed to the host's enrolled identity; the raw secret is NEVER stored or logged (INV5/INV9)
|
||||||
|
* and each wrap is distinct (per-host revocable, INV12). P2 unwraps locally with its private key.
|
||||||
|
*/
|
||||||
|
export async function mintHostContentSecret(agentPubkey: Uint8Array): Promise<Uint8Array> {
|
||||||
|
const secret = new Uint8Array(randomBytes(32))
|
||||||
|
const wrapped = sealToX25519(agentPubkey, secret)
|
||||||
|
secret.fill(0) // best-effort scrub of the raw secret
|
||||||
|
return wrapped
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedeemDeps {
|
||||||
|
readonly pairing: PairingStore
|
||||||
|
readonly hosts: HostRegistry
|
||||||
|
readonly subdomains: SubdomainAssigner
|
||||||
|
readonly leafSigner: LeafSigner
|
||||||
|
readonly pairingMaxRedeemAttempts: number
|
||||||
|
readonly audit?: AuditWriter
|
||||||
|
/** Injectable content-secret minter (test seam); defaults to `mintHostContentSecret`. */
|
||||||
|
readonly mintSecret?: (agentPubkey: Uint8Array) => Promise<Uint8Array>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairingRedeemer {
|
||||||
|
redeemPairingCode(input: RedeemInput): Promise<EnrollResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPem(label: string, der: Uint8Array): string {
|
||||||
|
const b64 = bytesToBase64(der)
|
||||||
|
const lines = b64.match(/.{1,64}/g) ?? [b64]
|
||||||
|
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPairingRedeemer(deps: RedeemDeps): PairingRedeemer {
|
||||||
|
const audit = deps.audit ?? noopAuditWriter()
|
||||||
|
const mint = deps.mintSecret ?? mintHostContentSecret
|
||||||
|
return {
|
||||||
|
async redeemPairingCode(input) {
|
||||||
|
const codeHash = sha256Hex(normalizePairingCode(input.code))
|
||||||
|
const row = await deps.pairing.get(codeHash)
|
||||||
|
if (row === null) throw new RedeemError('unknown') // blind guess; P5 limiter covers volume
|
||||||
|
|
||||||
|
// Lockout FIRST — a locked code is refused even with a correct code (Finding-4).
|
||||||
|
if (row.redeemAttempts >= deps.pairingMaxRedeemAttempts) throw new RedeemError('too_many_attempts')
|
||||||
|
if (Date.parse(row.record.expiresAt) <= Date.now()) throw new RedeemError('expired')
|
||||||
|
if (row.record.redeemedAt !== null) throw new RedeemError('already_redeemed')
|
||||||
|
|
||||||
|
// CSR proof-of-possession + no-substitution. A failure counts toward the code-scoped lockout.
|
||||||
|
const pop = verifyCsrPoP(input.csr)
|
||||||
|
if (!pop.ok || !timingSafeEqualBytes(pop.embeddedPub, input.agentPubkey)) {
|
||||||
|
await deps.pairing.registerFailure(codeHash)
|
||||||
|
throw new RedeemError('bad_csr')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic single-use CAS (double-spend guard) — exactly one concurrent caller wins.
|
||||||
|
const cas = await deps.pairing.casRedeem(codeHash, nowIso())
|
||||||
|
if (cas !== 'ok') throw new RedeemError('already_redeemed')
|
||||||
|
|
||||||
|
const accountId = row.record.accountId // from the pairing row (INV3)
|
||||||
|
const subdomain = await deps.subdomains.assignSubdomain(accountId)
|
||||||
|
const host = await deps.hosts.bindHost({
|
||||||
|
accountId,
|
||||||
|
subdomain,
|
||||||
|
agentPubkey: input.agentPubkey,
|
||||||
|
enrollFpr: fingerprint(input.agentPubkey),
|
||||||
|
})
|
||||||
|
const leaf = await deps.leafSigner.signHostLeaf(host.hostId, input.agentPubkey, input.csr)
|
||||||
|
const hostContentSecret = await mint(input.agentPubkey)
|
||||||
|
|
||||||
|
await audit.writeAuditEvent({
|
||||||
|
action: 'pairing.redeem',
|
||||||
|
principalId: `host:${host.hostId}`,
|
||||||
|
accountId,
|
||||||
|
hostId: host.hostId,
|
||||||
|
ts: nowIso(),
|
||||||
|
meta: { subdomain },
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
hostId: host.hostId,
|
||||||
|
subdomain,
|
||||||
|
cert: toPem('CERTIFICATE', leaf.cert),
|
||||||
|
caChain: leaf.caChain.map((der) => toPem('CERTIFICATE', der)).join(''),
|
||||||
|
hostContentSecret,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
63
control-plane/src/registry/accounts.ts
Normal file
63
control-plane/src/registry/accounts.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* T3 — account registry (immutable, INV8). `createAccount` writes a version-1 snapshot;
|
||||||
|
* `setAccountStatus` performs the atomic append-version + pointer-swap (prior snapshot stays
|
||||||
|
* readable from `versions()`). Never mutates a lifecycle field in place.
|
||||||
|
*/
|
||||||
|
import type { AccountRecord, AccountStatus } from '../model/records.js'
|
||||||
|
import type { AccountStore } from '../store/ports.js'
|
||||||
|
import type { AuditWriter } from '../audit/log.js'
|
||||||
|
import { noopAuditWriter } from '../audit/log.js'
|
||||||
|
import type { PlanTier } from 'relay-contracts'
|
||||||
|
import { newUuid, nowIso } from '../util/ids.js'
|
||||||
|
|
||||||
|
export interface AccountRegistry {
|
||||||
|
createAccount(plan: PlanTier): Promise<AccountRecord>
|
||||||
|
getAccount(accountId: string): Promise<AccountRecord | null>
|
||||||
|
setAccountStatus(accountId: string, status: AccountStatus): Promise<AccountRecord>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountRegistryDeps {
|
||||||
|
readonly accounts: AccountStore
|
||||||
|
readonly audit?: AuditWriter
|
||||||
|
readonly actor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAccountRegistry(deps: AccountRegistryDeps): AccountRegistry {
|
||||||
|
const audit = deps.audit ?? noopAuditWriter()
|
||||||
|
const actor = deps.actor ?? 'system'
|
||||||
|
return {
|
||||||
|
async createAccount(plan) {
|
||||||
|
const rec: AccountRecord = {
|
||||||
|
accountId: newUuid(), // unguessable, never recycled (INV1 precondition)
|
||||||
|
plan,
|
||||||
|
createdAt: nowIso(),
|
||||||
|
status: 'active',
|
||||||
|
}
|
||||||
|
await deps.accounts.insert(rec)
|
||||||
|
await audit.writeAuditEvent({
|
||||||
|
action: 'account.create',
|
||||||
|
principalId: actor,
|
||||||
|
accountId: rec.accountId,
|
||||||
|
hostId: null,
|
||||||
|
ts: rec.createdAt,
|
||||||
|
meta: { plan },
|
||||||
|
})
|
||||||
|
return rec
|
||||||
|
},
|
||||||
|
async getAccount(accountId) {
|
||||||
|
return deps.accounts.get(accountId)
|
||||||
|
},
|
||||||
|
async setAccountStatus(accountId, status) {
|
||||||
|
const next = await deps.accounts.swapStatus(accountId, status, actor) // NEW snapshot, atomic swap
|
||||||
|
await audit.writeAuditEvent({
|
||||||
|
action: status === 'suspended' ? 'account.suspend' : 'manage',
|
||||||
|
principalId: actor,
|
||||||
|
accountId,
|
||||||
|
hostId: null,
|
||||||
|
ts: nowIso(),
|
||||||
|
meta: { status },
|
||||||
|
})
|
||||||
|
return next
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
103
control-plane/src/registry/hosts.ts
Normal file
103
control-plane/src/registry/hosts.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* T4 — host registry: the ownership source of truth (INV1). A host is resolvable ONLY via
|
||||||
|
* `hostId`/`subdomain` bound to an account — there is NO raw address/port/hostname resolution
|
||||||
|
* path in this module. `ownsHost` is the deny-by-default ownership predicate (INV1/INV6). DB
|
||||||
|
* stores only the PUBLIC Ed25519 key (INV4). Status changes are versioned snapshots (INV8).
|
||||||
|
*/
|
||||||
|
import type { HostRecord, HostStatus } from '../model/records.js'
|
||||||
|
import type { HostStore } from '../store/ports.js'
|
||||||
|
import type { AuditWriter } from '../audit/log.js'
|
||||||
|
import { noopAuditWriter } from '../audit/log.js'
|
||||||
|
import { newUuid, nowIso } from '../util/ids.js'
|
||||||
|
import { fingerprint } from '../ca/fingerprint.js'
|
||||||
|
|
||||||
|
export interface BindHostInput {
|
||||||
|
readonly accountId: string
|
||||||
|
readonly subdomain: string
|
||||||
|
readonly agentPubkey: Uint8Array
|
||||||
|
readonly enrollFpr: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostRegistry {
|
||||||
|
bindHost(input: BindHostInput): Promise<HostRecord>
|
||||||
|
getHost(hostId: string): Promise<HostRecord | null>
|
||||||
|
getHostBySubdomain(subdomain: string): Promise<HostRecord | null>
|
||||||
|
listHosts(accountId: string): Promise<readonly HostRecord[]>
|
||||||
|
setHostStatus(hostId: string, status: HostStatus): Promise<HostRecord>
|
||||||
|
ownsHost(accountId: string, hostId: string): Promise<boolean>
|
||||||
|
touchLastSeen(hostId: string): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostRegistryDeps {
|
||||||
|
readonly hosts: HostStore
|
||||||
|
readonly audit?: AuditWriter
|
||||||
|
readonly actor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHostRegistry(deps: HostRegistryDeps): HostRegistry {
|
||||||
|
const audit = deps.audit ?? noopAuditWriter()
|
||||||
|
const actor = deps.actor ?? 'system'
|
||||||
|
return {
|
||||||
|
async bindHost(input) {
|
||||||
|
// enroll_fpr MUST be the fingerprint of the presented pubkey (defence-in-depth: recompute).
|
||||||
|
const expectedFpr = fingerprint(input.agentPubkey)
|
||||||
|
if (input.enrollFpr !== expectedFpr) {
|
||||||
|
throw new Error('enrollFpr does not match agentPubkey fingerprint')
|
||||||
|
}
|
||||||
|
const rec: HostRecord = {
|
||||||
|
hostId: newUuid(), // unguessable UUIDv4, never recycled (INV1)
|
||||||
|
accountId: input.accountId,
|
||||||
|
subdomain: input.subdomain,
|
||||||
|
// Defensive copy → fresh ArrayBuffer-backed array (immutability + TS6 variance). PUBLIC key only (INV4).
|
||||||
|
agentPubkey: new Uint8Array(input.agentPubkey),
|
||||||
|
enrollFpr: input.enrollFpr,
|
||||||
|
status: 'offline',
|
||||||
|
lastSeen: nowIso(),
|
||||||
|
createdAt: nowIso(),
|
||||||
|
revokedAt: null,
|
||||||
|
}
|
||||||
|
await deps.hosts.insert(rec)
|
||||||
|
await audit.writeAuditEvent({
|
||||||
|
action: 'host.bind',
|
||||||
|
principalId: actor,
|
||||||
|
accountId: input.accountId,
|
||||||
|
hostId: rec.hostId,
|
||||||
|
ts: rec.createdAt,
|
||||||
|
meta: { subdomain: input.subdomain, enrollFpr: input.enrollFpr },
|
||||||
|
})
|
||||||
|
return rec
|
||||||
|
},
|
||||||
|
async getHost(hostId) {
|
||||||
|
return deps.hosts.get(hostId)
|
||||||
|
},
|
||||||
|
async getHostBySubdomain(subdomain) {
|
||||||
|
return deps.hosts.getBySubdomain(subdomain)
|
||||||
|
},
|
||||||
|
async listHosts(accountId) {
|
||||||
|
return deps.hosts.listByAccount(accountId) // ownership-scoped
|
||||||
|
},
|
||||||
|
async setHostStatus(hostId, status) {
|
||||||
|
const revokedAt = status === 'revoked' ? nowIso() : null
|
||||||
|
const next = await deps.hosts.swapStatus(hostId, status, revokedAt, actor)
|
||||||
|
if (status === 'revoked') {
|
||||||
|
await audit.writeAuditEvent({
|
||||||
|
action: 'host.revoke',
|
||||||
|
principalId: actor,
|
||||||
|
accountId: next.accountId,
|
||||||
|
hostId,
|
||||||
|
ts: nowIso(),
|
||||||
|
meta: { status },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
},
|
||||||
|
async ownsHost(accountId, hostId) {
|
||||||
|
const host = await deps.hosts.get(hostId)
|
||||||
|
// Deny-by-default: unknown host or account mismatch ⇒ false (INV1/INV6).
|
||||||
|
return host !== null && host.accountId === accountId
|
||||||
|
},
|
||||||
|
async touchLastSeen(hostId) {
|
||||||
|
await deps.hosts.touchLastSeen(hostId, nowIso()) // advisory, not versioned (INV7)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
49
control-plane/src/registry/sessions.ts
Normal file
49
control-plane/src/registry/sessions.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* T5 — session registry. `account_id` is DENORMALIZED from the host at write time — NEVER
|
||||||
|
* supplied by the caller/client (INV3) — giving an O(1) deny-by-default authz fact for reattach
|
||||||
|
* (INV6). `sessionAccount` returns that server-derived owner; it never echoes a client value.
|
||||||
|
*/
|
||||||
|
import type { SessionRecord } from '../model/records.js'
|
||||||
|
import type { HostStore, SessionStore } from '../store/ports.js'
|
||||||
|
import { nowIso } from '../util/ids.js'
|
||||||
|
|
||||||
|
export interface RecordSessionInput {
|
||||||
|
readonly sessionId: string
|
||||||
|
readonly hostId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionRegistry {
|
||||||
|
recordSession(input: RecordSessionInput): Promise<SessionRecord>
|
||||||
|
getSession(sessionId: string): Promise<SessionRecord | null>
|
||||||
|
sessionAccount(sessionId: string): Promise<string | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionRegistryDeps {
|
||||||
|
readonly sessions: SessionStore
|
||||||
|
readonly hosts: HostStore
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSessionRegistry(deps: SessionRegistryDeps): SessionRegistry {
|
||||||
|
return {
|
||||||
|
async recordSession(input) {
|
||||||
|
const host = await deps.hosts.get(input.hostId)
|
||||||
|
if (host === null) throw new Error('cannot record session for unknown host')
|
||||||
|
const rec: SessionRecord = {
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
hostId: input.hostId,
|
||||||
|
accountId: host.accountId, // derived from the host — caller cannot influence (INV3)
|
||||||
|
createdAt: nowIso(),
|
||||||
|
lastAttachAt: nowIso(),
|
||||||
|
}
|
||||||
|
await deps.sessions.insert(rec)
|
||||||
|
return rec
|
||||||
|
},
|
||||||
|
async getSession(sessionId) {
|
||||||
|
return deps.sessions.get(sessionId)
|
||||||
|
},
|
||||||
|
async sessionAccount(sessionId) {
|
||||||
|
const s = await deps.sessions.get(sessionId)
|
||||||
|
return s?.accountId ?? null // O(1) authz fact; server-derived, never client-supplied
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
109
control-plane/src/revoke/revoke.ts
Normal file
109
control-plane/src/revoke/revoke.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* T13 — revocation (global + per-host), INV12. The within-seconds tunnel-kill PUBLISHES a
|
||||||
|
* KillSignal on the FROZEN `relay:revocations` bus (INDEX §4.2 FIX 4 — SAME channel T10 drain
|
||||||
|
* uses); every P1 node subscribes and injects the §4.1 CLOSE+RST within REVOCATION_PUSH_BUDGET_MS.
|
||||||
|
* KillSignal / RevocationScope / the channel + budget constants are IMPORTED from relay-contracts,
|
||||||
|
* never redefined. `reason` is metadata only (INV10, zero payload). Revocation writes an immutable
|
||||||
|
* host snapshot (INV8) and is idempotent.
|
||||||
|
*/
|
||||||
|
import { REVOCATION_PUSH_BUDGET_MS, type RevocationBus } from 'relay-contracts'
|
||||||
|
import type { HostRegistry } from '../registry/hosts.js'
|
||||||
|
import type { RoutingTable } from '../routing/table.js'
|
||||||
|
import type { AuditWriter } from '../audit/log.js'
|
||||||
|
import { noopAuditWriter } from '../audit/log.js'
|
||||||
|
|
||||||
|
export { REVOCATION_PUSH_BUDGET_MS }
|
||||||
|
|
||||||
|
/** Short-TTL token revocation store (Redis `revoked:{jti}` in prod). Shared key space with P5. */
|
||||||
|
export interface RevokedTokenStore {
|
||||||
|
revoke(jti: string, ttlSec: number): Promise<void>
|
||||||
|
isRevoked(jti: string): Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In-memory implementation with TTL expiry (default; Redis is the production swap). */
|
||||||
|
export function createInMemoryRevokedTokenStore(): RevokedTokenStore {
|
||||||
|
const until = new Map<string, number>()
|
||||||
|
return {
|
||||||
|
async revoke(jti, ttlSec) {
|
||||||
|
until.set(jti, Date.now() + Math.max(0, ttlSec) * 1000)
|
||||||
|
},
|
||||||
|
async isRevoked(jti) {
|
||||||
|
const exp = until.get(jti)
|
||||||
|
if (exp === undefined) return false
|
||||||
|
if (exp <= Date.now()) {
|
||||||
|
until.delete(jti)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Revoker {
|
||||||
|
revokeHost(hostId: string): Promise<void>
|
||||||
|
revokeAccount(accountId: string): Promise<void>
|
||||||
|
revokeToken(jti: string, expUnix: number): Promise<void>
|
||||||
|
isTokenRevoked(jti: string): Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevokerDeps {
|
||||||
|
readonly hosts: HostRegistry
|
||||||
|
readonly routing: RoutingTable
|
||||||
|
readonly bus: RevocationBus
|
||||||
|
readonly tokens: RevokedTokenStore
|
||||||
|
readonly audit?: AuditWriter
|
||||||
|
readonly actor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRevoker(deps: RevokerDeps): Revoker {
|
||||||
|
const audit = deps.audit ?? noopAuditWriter()
|
||||||
|
const actor = deps.actor ?? 'system'
|
||||||
|
const at = () => Math.floor(Date.now() / 1000)
|
||||||
|
return {
|
||||||
|
async revokeHost(hostId) {
|
||||||
|
const host = await deps.hosts.getHost(hostId)
|
||||||
|
if (host === null) return // idempotent: nothing to revoke
|
||||||
|
if (host.status !== 'revoked') {
|
||||||
|
await deps.hosts.setHostStatus(hostId, 'revoked') // immutable snapshot (INV8) + audit host.revoke
|
||||||
|
}
|
||||||
|
await deps.routing.systemDropRoute(hostId) // resolveRoute → null after this
|
||||||
|
await deps.bus.publish({ scope: { kind: 'host', hostId }, at: at(), reason: 'revoked' })
|
||||||
|
await audit.writeAuditEvent({
|
||||||
|
action: 'revoke',
|
||||||
|
principalId: actor,
|
||||||
|
accountId: host.accountId,
|
||||||
|
hostId,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
meta: { scope: 'host' },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async revokeAccount(accountId) {
|
||||||
|
const hosts = await deps.hosts.listHosts(accountId)
|
||||||
|
for (const h of hosts) {
|
||||||
|
if (h.status !== 'revoked') {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await deps.hosts.setHostStatus(h.hostId, 'revoked')
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await deps.routing.systemDropRoute(h.hostId)
|
||||||
|
}
|
||||||
|
// One account-scoped KillSignal; P1 tears down all of the account's live streams.
|
||||||
|
await deps.bus.publish({ scope: { kind: 'account', accountId }, at: at(), reason: 'revoked' })
|
||||||
|
await audit.writeAuditEvent({
|
||||||
|
action: 'revoke',
|
||||||
|
principalId: actor,
|
||||||
|
accountId,
|
||||||
|
hostId: null,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
meta: { scope: 'account', hosts: String(hosts.length) },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async revokeToken(jti, expUnix) {
|
||||||
|
const ttlSec = Math.max(0, expUnix - Math.floor(Date.now() / 1000))
|
||||||
|
await deps.tokens.revoke(jti, ttlSec)
|
||||||
|
},
|
||||||
|
async isTokenRevoked(jti) {
|
||||||
|
return deps.tokens.isRevoked(jti)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
49
control-plane/src/routing/bus.ts
Normal file
49
control-plane/src/routing/bus.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* RevocationBus implementations over the FROZEN `relay:revocations` channel (INDEX §4.2 FIX 4).
|
||||||
|
* P3 only PUBLISHES (P1 owns the subscriber that injects §4.1 CLOSE+RST / GOAWAY). Drain (T10)
|
||||||
|
* and revoke (T13) use the SAME named channel. The KillSignal shape + channel name are imported
|
||||||
|
* from relay-contracts and NEVER redefined.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
RELAY_REVOCATIONS_CHANNEL,
|
||||||
|
KillSignalSchema,
|
||||||
|
type KillSignal,
|
||||||
|
type RevocationBus,
|
||||||
|
} from 'relay-contracts'
|
||||||
|
|
||||||
|
/** In-memory bus with a subscribe hook — used by tests and single-process wiring. */
|
||||||
|
export interface TestableRevocationBus extends RevocationBus {
|
||||||
|
subscribe(handler: (signal: KillSignal) => void): () => void
|
||||||
|
readonly channel: typeof RELAY_REVOCATIONS_CHANNEL
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInMemoryRevocationBus(): TestableRevocationBus {
|
||||||
|
const handlers = new Set<(signal: KillSignal) => void>()
|
||||||
|
return {
|
||||||
|
channel: RELAY_REVOCATIONS_CHANNEL,
|
||||||
|
async publish(signal) {
|
||||||
|
// Validate at the boundary before it hits the wire (INV10: reason is metadata only).
|
||||||
|
const parsed = KillSignalSchema.parse(signal)
|
||||||
|
for (const h of handlers) h(parsed as KillSignal)
|
||||||
|
},
|
||||||
|
subscribe(handler) {
|
||||||
|
handlers.add(handler)
|
||||||
|
return () => handlers.delete(handler)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal publisher surface of an ioredis client (integration seam; typed, not unit-tested). */
|
||||||
|
export interface RedisPublisher {
|
||||||
|
publish(channel: string, message: string): Promise<number>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redis-backed publisher (production). P1 relay nodes SUBSCRIBE to the same channel. */
|
||||||
|
export function createRedisRevocationBus(redis: RedisPublisher): RevocationBus {
|
||||||
|
return {
|
||||||
|
async publish(signal) {
|
||||||
|
const parsed = KillSignalSchema.parse(signal)
|
||||||
|
await redis.publish(RELAY_REVOCATIONS_CHANNEL, JSON.stringify(parsed))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
67
control-plane/src/routing/nodes.ts
Normal file
67
control-plane/src/routing/nodes.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* T10 — relay-node registry + graceful drain (INV7). Every mutation is scoped to the caller's
|
||||||
|
* authenticated `NodeIdentity`: a node can only register/heartbeat/drain ITSELF. Drain marks the
|
||||||
|
* node 'draining', enumerates its live routes, and PUBLISHES a per-host `KillSignal` on the frozen
|
||||||
|
* `relay:revocations` bus (INDEX §4.2 FIX 4 — SAME channel T13 revoke uses); P1 nodes translate it
|
||||||
|
* into the §4.1 GOAWAY. This module does NOT encode the frame (that is P1). Drain touches NO
|
||||||
|
* Postgres host/session row — the running task lives on the customer machine (PTY survives).
|
||||||
|
*
|
||||||
|
* OQ4 residual: the frozen RevocationScope has no `node` kind, so a single-node operator drain is
|
||||||
|
* expressed as per-host KillSignals with a drain reason (flagged to the INDEX for a future scope).
|
||||||
|
*/
|
||||||
|
import type { RevocationBus } from 'relay-contracts'
|
||||||
|
import type { NodeStore, RouteStore } from '../store/ports.js'
|
||||||
|
import type { NodeIdentity } from '../node-auth/identity.js'
|
||||||
|
import type { AuditWriter } from '../audit/log.js'
|
||||||
|
import { noopAuditWriter } from '../audit/log.js'
|
||||||
|
import { nowIso } from '../util/ids.js'
|
||||||
|
|
||||||
|
export const DRAIN_REASON = 'operatorDrain'
|
||||||
|
|
||||||
|
export interface NodeCoordinator {
|
||||||
|
registerNode(caller: NodeIdentity, addr: string): Promise<void>
|
||||||
|
nodeHeartbeat(caller: NodeIdentity): Promise<void>
|
||||||
|
beginDrain(caller: NodeIdentity): Promise<{ readonly hostIds: readonly string[] }>
|
||||||
|
completeDrain(caller: NodeIdentity): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeCoordinatorDeps {
|
||||||
|
readonly nodes: NodeStore
|
||||||
|
readonly routes: RouteStore
|
||||||
|
readonly bus: RevocationBus
|
||||||
|
readonly audit?: AuditWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNodeCoordinator(deps: NodeCoordinatorDeps): NodeCoordinator {
|
||||||
|
const audit = deps.audit ?? noopAuditWriter()
|
||||||
|
return {
|
||||||
|
async registerNode(caller, addr) {
|
||||||
|
await deps.nodes.upsert({ nodeId: caller.nodeId, addr, status: 'active', lastSeen: nowIso() })
|
||||||
|
},
|
||||||
|
async nodeHeartbeat(caller) {
|
||||||
|
await deps.nodes.touch(caller.nodeId, nowIso())
|
||||||
|
},
|
||||||
|
async beginDrain(caller) {
|
||||||
|
await deps.nodes.setStatus(caller.nodeId, 'draining')
|
||||||
|
const routed = await deps.routes.listByNode(caller.nodeId)
|
||||||
|
const at = Math.floor(Date.now() / 1000)
|
||||||
|
for (const { hostId } of routed) {
|
||||||
|
// Per-host KillSignal with a drain reason on the frozen bus (P1 emits GOAWAY).
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await deps.bus.publish({ scope: { kind: 'host', hostId }, at, reason: DRAIN_REASON })
|
||||||
|
}
|
||||||
|
await audit.writeAuditEvent({
|
||||||
|
action: 'node.drain',
|
||||||
|
principalId: `node:${caller.nodeId}`,
|
||||||
|
accountId: 'system',
|
||||||
|
hostId: null,
|
||||||
|
ts: nowIso(),
|
||||||
|
meta: { nodeId: caller.nodeId, routes: String(routed.length) },
|
||||||
|
})
|
||||||
|
return { hostIds: routed.map((r) => r.hostId) }
|
||||||
|
},
|
||||||
|
async completeDrain(caller) {
|
||||||
|
await deps.nodes.delete(caller.nodeId) // deregister after routes re-homed
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
62
control-plane/src/routing/table.ts
Normal file
62
control-plane/src/routing/table.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* T9 — Redis routing table (`route:{host_id}`, heartbeat-TTL, INV7). Redis holds only LOCATION,
|
||||||
|
* never ownership or secrets; a missing/expired key fails CLOSED (host treated offline). Every
|
||||||
|
* mutation is scoped to the AUTHENTICATED node identity: a node can only route hosts to ITSELF
|
||||||
|
* (`entry.relayNodeId === caller.nodeId`) and can only heartbeat/drop a route it currently holds.
|
||||||
|
*/
|
||||||
|
import type { RouteEntry } from '../model/records.js'
|
||||||
|
import type { NodeStatus, RouteStore } from '../store/ports.js'
|
||||||
|
import type { NodeIdentity } from '../node-auth/identity.js'
|
||||||
|
|
||||||
|
export class RouteAuthError extends Error {}
|
||||||
|
|
||||||
|
export interface RoutingTable {
|
||||||
|
upsertRoute(caller: NodeIdentity, hostId: string, entry: RouteEntry, ttlSec: number): Promise<void>
|
||||||
|
heartbeatRoute(caller: NodeIdentity, hostId: string, ttlSec: number): Promise<void>
|
||||||
|
resolveRoute(hostId: string): Promise<RouteEntry | null>
|
||||||
|
dropRoute(caller: NodeIdentity, hostId: string): Promise<void>
|
||||||
|
/** System teardown path (drain/revoke) — bypasses the holding-node check by design. */
|
||||||
|
systemDropRoute(hostId: string): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutingTableDeps {
|
||||||
|
readonly routes: RouteStore
|
||||||
|
/** Optional node-status hook: a draining node stops receiving new upserts (T10). */
|
||||||
|
readonly nodeStatus?: (nodeId: string) => Promise<NodeStatus | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRoutingTable(deps: RoutingTableDeps): RoutingTable {
|
||||||
|
return {
|
||||||
|
async upsertRoute(caller, hostId, entry, ttlSec) {
|
||||||
|
// A node may ONLY route hosts to itself — never inject a route for another node's id.
|
||||||
|
if (entry.relayNodeId !== caller.nodeId) {
|
||||||
|
throw new RouteAuthError('entry.relayNodeId must equal the authenticated caller nodeId')
|
||||||
|
}
|
||||||
|
if (deps.nodeStatus !== undefined) {
|
||||||
|
const status = await deps.nodeStatus(caller.nodeId)
|
||||||
|
if (status === 'draining') throw new RouteAuthError('node is draining; not accepting new routes')
|
||||||
|
}
|
||||||
|
await deps.routes.set(hostId, entry, ttlSec)
|
||||||
|
},
|
||||||
|
async heartbeatRoute(caller, hostId, ttlSec) {
|
||||||
|
const current = await deps.routes.get(hostId)
|
||||||
|
// A stale/foreign node cannot refresh (hijack) another node's live tunnel; missing ⇒ no-op.
|
||||||
|
if (current === null || current.relayNodeId !== caller.nodeId) return
|
||||||
|
await deps.routes.refreshTtl(hostId, ttlSec)
|
||||||
|
},
|
||||||
|
async resolveRoute(hostId) {
|
||||||
|
return deps.routes.get(hostId) // null ⇒ offline (fails closed, INV7)
|
||||||
|
},
|
||||||
|
async dropRoute(caller, hostId) {
|
||||||
|
const current = await deps.routes.get(hostId)
|
||||||
|
if (current === null) return
|
||||||
|
if (current.relayNodeId !== caller.nodeId) {
|
||||||
|
throw new RouteAuthError('only the holding node may drop its route')
|
||||||
|
}
|
||||||
|
await deps.routes.delete(hostId)
|
||||||
|
},
|
||||||
|
async systemDropRoute(hostId) {
|
||||||
|
await deps.routes.delete(hostId)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
320
control-plane/src/store/memory.ts
Normal file
320
control-plane/src/store/memory.ts
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
/**
|
||||||
|
* In-memory adapter for the repository ports — the tested + v0.9-MVP-shipped default.
|
||||||
|
*
|
||||||
|
* INV8: lifecycle/business fields (account.status, host.status/revoked_at) are versioned via
|
||||||
|
* append-only companion rows + an atomic single-row pointer swap. Each `swapStatus` mutation is
|
||||||
|
* a synchronous critical section (no `await` inside) so a concurrent reader sees whole-old or
|
||||||
|
* whole-new — never a torn mix. Prior snapshots stay readable from the `versions()` list.
|
||||||
|
* High-frequency liveness (`last_seen`, route TTL) is advisory and NOT versioned (INV7).
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
AccountRecord,
|
||||||
|
AccountStatus,
|
||||||
|
AccountStatusVersionRow,
|
||||||
|
HostRecord,
|
||||||
|
HostStatus,
|
||||||
|
HostStatusVersionRow,
|
||||||
|
MeteringSampleRow,
|
||||||
|
PairingCodeRecord,
|
||||||
|
RouteEntry,
|
||||||
|
SessionRecord,
|
||||||
|
} from '../model/records.js'
|
||||||
|
import type {
|
||||||
|
AccountStore,
|
||||||
|
AuditRow,
|
||||||
|
AuditStore,
|
||||||
|
CasOutcome,
|
||||||
|
HostStore,
|
||||||
|
MeteringStore,
|
||||||
|
NodeRow,
|
||||||
|
NodeStatus,
|
||||||
|
NodeStore,
|
||||||
|
PairingRow,
|
||||||
|
PairingStore,
|
||||||
|
RouteStore,
|
||||||
|
SessionStore,
|
||||||
|
Stores,
|
||||||
|
SubdomainStore,
|
||||||
|
} from './ports.js'
|
||||||
|
|
||||||
|
interface AccountCell {
|
||||||
|
record: AccountRecord
|
||||||
|
version: number
|
||||||
|
versions: AccountStatusVersionRow[]
|
||||||
|
}
|
||||||
|
interface HostCell {
|
||||||
|
record: HostRecord
|
||||||
|
version: number
|
||||||
|
versions: HostStatusVersionRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function memAccountStore(): AccountStore {
|
||||||
|
const cells = new Map<string, AccountCell>()
|
||||||
|
return {
|
||||||
|
async insert(rec) {
|
||||||
|
if (cells.has(rec.accountId)) throw new Error('duplicate accountId')
|
||||||
|
cells.set(rec.accountId, {
|
||||||
|
record: rec,
|
||||||
|
version: 1,
|
||||||
|
versions: [
|
||||||
|
{ accountId: rec.accountId, version: 1, status: rec.status, changedAt: rec.createdAt, changedBy: 'system' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async get(id) {
|
||||||
|
return cells.get(id)?.record ?? null
|
||||||
|
},
|
||||||
|
async swapStatus(id, status, changedBy) {
|
||||||
|
const cell = cells.get(id)
|
||||||
|
if (cell === undefined) throw new Error('account not found')
|
||||||
|
// --- atomic critical section (no await) ---
|
||||||
|
const nextVersion = cell.version + 1
|
||||||
|
const changedAt = new Date().toISOString()
|
||||||
|
const nextRecord: AccountRecord = { ...cell.record, status }
|
||||||
|
cell.versions.push({ accountId: id, version: nextVersion, status, changedAt, changedBy })
|
||||||
|
cell.record = nextRecord
|
||||||
|
cell.version = nextVersion
|
||||||
|
// --- end critical section ---
|
||||||
|
return nextRecord
|
||||||
|
},
|
||||||
|
async versions(id) {
|
||||||
|
return (cells.get(id)?.versions ?? []).slice()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function memHostStore(): HostStore {
|
||||||
|
const cells = new Map<string, HostCell>()
|
||||||
|
const bySub = new Map<string, string>()
|
||||||
|
return {
|
||||||
|
async insert(rec) {
|
||||||
|
if (cells.has(rec.hostId)) throw new Error('duplicate hostId')
|
||||||
|
if (bySub.has(rec.subdomain)) throw new Error('duplicate subdomain')
|
||||||
|
cells.set(rec.hostId, {
|
||||||
|
record: rec,
|
||||||
|
version: 1,
|
||||||
|
versions: [
|
||||||
|
{
|
||||||
|
hostId: rec.hostId,
|
||||||
|
version: 1,
|
||||||
|
status: rec.status,
|
||||||
|
revokedAt: rec.revokedAt,
|
||||||
|
changedAt: rec.createdAt,
|
||||||
|
changedBy: 'system',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
bySub.set(rec.subdomain, rec.hostId)
|
||||||
|
},
|
||||||
|
async get(id) {
|
||||||
|
return cells.get(id)?.record ?? null
|
||||||
|
},
|
||||||
|
async getBySubdomain(sub) {
|
||||||
|
const id = bySub.get(sub)
|
||||||
|
return id === undefined ? null : (cells.get(id)?.record ?? null)
|
||||||
|
},
|
||||||
|
async listByAccount(accountId) {
|
||||||
|
return [...cells.values()].filter((c) => c.record.accountId === accountId).map((c) => c.record)
|
||||||
|
},
|
||||||
|
async swapStatus(id, status, revokedAt, changedBy) {
|
||||||
|
const cell = cells.get(id)
|
||||||
|
if (cell === undefined) throw new Error('host not found')
|
||||||
|
const nextVersion = cell.version + 1
|
||||||
|
const changedAt = new Date().toISOString()
|
||||||
|
const nextRecord: HostRecord = { ...cell.record, status, revokedAt }
|
||||||
|
cell.versions.push({ hostId: id, version: nextVersion, status, revokedAt, changedAt, changedBy })
|
||||||
|
cell.record = nextRecord
|
||||||
|
cell.version = nextVersion
|
||||||
|
return nextRecord
|
||||||
|
},
|
||||||
|
async touchLastSeen(id, ts) {
|
||||||
|
const cell = cells.get(id)
|
||||||
|
if (cell === undefined) return
|
||||||
|
// Advisory cache — overwrite in place, NO version row (INV7).
|
||||||
|
cell.record = { ...cell.record, lastSeen: ts }
|
||||||
|
},
|
||||||
|
async versions(id) {
|
||||||
|
return (cells.get(id)?.versions ?? []).slice()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function memSessionStore(): SessionStore {
|
||||||
|
const rows = new Map<string, SessionRecord>()
|
||||||
|
return {
|
||||||
|
async insert(rec) {
|
||||||
|
if (rows.has(rec.sessionId)) throw new Error('duplicate sessionId')
|
||||||
|
rows.set(rec.sessionId, rec)
|
||||||
|
},
|
||||||
|
async get(id) {
|
||||||
|
return rows.get(id) ?? null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function memSubdomainStore(hosts: HostStore): SubdomainStore {
|
||||||
|
const reserved = new Set<string>()
|
||||||
|
return {
|
||||||
|
async reserve(sub) {
|
||||||
|
// Synchronous claim FIRST (no await before add) so two concurrent callers can't both win.
|
||||||
|
if (reserved.has(sub)) return false
|
||||||
|
reserved.add(sub)
|
||||||
|
if ((await hosts.getBySubdomain(sub)) !== null) {
|
||||||
|
reserved.delete(sub) // an already-bound host owns this label — release the optimistic claim
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
async isTaken(sub) {
|
||||||
|
return reserved.has(sub) || (await hosts.getBySubdomain(sub)) !== null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PairingCell {
|
||||||
|
record: PairingCodeRecord
|
||||||
|
attempts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function memPairingStore(): PairingStore {
|
||||||
|
const cells = new Map<string, PairingCell>()
|
||||||
|
return {
|
||||||
|
async insert(rec) {
|
||||||
|
if (cells.has(rec.codeHash)) throw new Error('duplicate code_hash')
|
||||||
|
cells.set(rec.codeHash, { record: rec, attempts: 0 })
|
||||||
|
},
|
||||||
|
async get(codeHash): Promise<PairingRow | null> {
|
||||||
|
const cell = cells.get(codeHash)
|
||||||
|
return cell === undefined ? null : { record: cell.record, redeemAttempts: cell.attempts }
|
||||||
|
},
|
||||||
|
async casRedeem(codeHash, nowIso): Promise<CasOutcome> {
|
||||||
|
const cell = cells.get(codeHash)
|
||||||
|
if (cell === undefined) return 'unknown'
|
||||||
|
// --- atomic critical section: CAS redeemedAt from null ---
|
||||||
|
if (cell.record.redeemedAt !== null) return 'already_redeemed'
|
||||||
|
cell.record = { ...cell.record, redeemedAt: nowIso }
|
||||||
|
return 'ok'
|
||||||
|
// --- end critical section ---
|
||||||
|
},
|
||||||
|
async registerFailure(codeHash) {
|
||||||
|
const cell = cells.get(codeHash)
|
||||||
|
if (cell === undefined) return 0
|
||||||
|
cell.attempts += 1
|
||||||
|
return cell.attempts
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteCell {
|
||||||
|
entry: RouteEntry
|
||||||
|
expiresAtMs: number
|
||||||
|
}
|
||||||
|
function memRouteStore(): RouteStore {
|
||||||
|
const cells = new Map<string, RouteCell>()
|
||||||
|
const live = (id: string): RouteCell | null => {
|
||||||
|
const c = cells.get(id)
|
||||||
|
if (c === undefined) return null
|
||||||
|
if (c.expiresAtMs <= Date.now()) {
|
||||||
|
cells.delete(id) // fail closed: expired ⇒ offline (INV7)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
async set(hostId, entry, ttlSec) {
|
||||||
|
cells.set(hostId, { entry, expiresAtMs: Date.now() + ttlSec * 1000 })
|
||||||
|
},
|
||||||
|
async refreshTtl(hostId, ttlSec) {
|
||||||
|
const c = live(hostId)
|
||||||
|
if (c === null) return false
|
||||||
|
c.expiresAtMs = Date.now() + ttlSec * 1000
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
async get(hostId) {
|
||||||
|
return live(hostId)?.entry ?? null
|
||||||
|
},
|
||||||
|
async delete(hostId) {
|
||||||
|
cells.delete(hostId)
|
||||||
|
},
|
||||||
|
async listByNode(nodeId) {
|
||||||
|
const out: { hostId: string; entry: RouteEntry }[] = []
|
||||||
|
for (const hostId of [...cells.keys()]) {
|
||||||
|
const c = live(hostId)
|
||||||
|
if (c !== null && c.entry.relayNodeId === nodeId) out.push({ hostId, entry: c.entry })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function memNodeStore(): NodeStore {
|
||||||
|
const rows = new Map<string, NodeRow>()
|
||||||
|
return {
|
||||||
|
async upsert(row) {
|
||||||
|
rows.set(row.nodeId, row)
|
||||||
|
},
|
||||||
|
async get(id) {
|
||||||
|
return rows.get(id) ?? null
|
||||||
|
},
|
||||||
|
async setStatus(id, status: NodeStatus) {
|
||||||
|
const r = rows.get(id)
|
||||||
|
if (r === undefined) throw new Error('node not found')
|
||||||
|
rows.set(id, { ...r, status })
|
||||||
|
},
|
||||||
|
async touch(id, ts) {
|
||||||
|
const r = rows.get(id)
|
||||||
|
if (r !== undefined) rows.set(id, { ...r, lastSeen: ts })
|
||||||
|
},
|
||||||
|
async delete(id) {
|
||||||
|
rows.delete(id)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function memMeteringStore(): MeteringStore {
|
||||||
|
const rows: MeteringSampleRow[] = []
|
||||||
|
return {
|
||||||
|
async append(row) {
|
||||||
|
rows.push(row) // append-only
|
||||||
|
},
|
||||||
|
async query(accountId, fromIso, toIso) {
|
||||||
|
const from = Date.parse(fromIso)
|
||||||
|
const to = Date.parse(toIso)
|
||||||
|
return rows.filter(
|
||||||
|
(r) => r.accountId === accountId && Date.parse(r.sampledAt) >= from && Date.parse(r.sampledAt) <= to,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function memAuditStore(): AuditStore {
|
||||||
|
const rows: AuditRow[] = []
|
||||||
|
return {
|
||||||
|
async append(row) {
|
||||||
|
rows.push(row) // append-only, no update/delete exposed (INV10)
|
||||||
|
},
|
||||||
|
async query(accountId, fromIso, toIso) {
|
||||||
|
const from = Date.parse(fromIso)
|
||||||
|
const to = Date.parse(toIso)
|
||||||
|
return rows.filter(
|
||||||
|
(r) => r.accountId === accountId && Date.parse(r.ts) >= from && Date.parse(r.ts) <= to,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a full in-memory store set (all ports wired). */
|
||||||
|
export function createMemoryStores(): Stores {
|
||||||
|
const hosts = memHostStore()
|
||||||
|
return {
|
||||||
|
accounts: memAccountStore(),
|
||||||
|
hosts,
|
||||||
|
sessions: memSessionStore(),
|
||||||
|
subdomains: memSubdomainStore(hosts),
|
||||||
|
pairing: memPairingStore(),
|
||||||
|
routes: memRouteStore(),
|
||||||
|
nodes: memNodeStore(),
|
||||||
|
metering: memMeteringStore(),
|
||||||
|
audit: memAuditStore(),
|
||||||
|
}
|
||||||
|
}
|
||||||
142
control-plane/src/store/ports.ts
Normal file
142
control-plane/src/store/ports.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Repository ports (Repository Pattern, patterns.md). Registries/services depend on these
|
||||||
|
* abstractions, NOT on a concrete storage engine. Two adapters implement them:
|
||||||
|
* - `store/memory.ts` — in-memory, the tested + v0.9-MVP-shipped default. Immutable/versioned
|
||||||
|
* discipline (INV8) is enforced here in synchronous critical sections (JS is single-threaded,
|
||||||
|
* so a mutation with no `await` inside is atomic — no torn read).
|
||||||
|
* - `store/pg.ts` — parameterized Postgres SQL over `db/pool.ts` (integration seam, typechecked).
|
||||||
|
*
|
||||||
|
* The INV8 versioning logic lives in the store so both adapters agree on the contract; the
|
||||||
|
* registries orchestrate and add authz/ownership predicates.
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
AccountRecord,
|
||||||
|
AccountStatus,
|
||||||
|
AccountStatusVersionRow,
|
||||||
|
HostRecord,
|
||||||
|
HostStatus,
|
||||||
|
HostStatusVersionRow,
|
||||||
|
MeteringSampleRow,
|
||||||
|
PairingCodeRecord,
|
||||||
|
RouteEntry,
|
||||||
|
SessionRecord,
|
||||||
|
} from '../model/records.js'
|
||||||
|
|
||||||
|
export interface AccountStore {
|
||||||
|
insert(rec: AccountRecord): Promise<void>
|
||||||
|
get(accountId: string): Promise<AccountRecord | null>
|
||||||
|
/** Atomic: append a status version then swap the single-row pointer. Returns the NEW record. */
|
||||||
|
swapStatus(accountId: string, status: AccountStatus, changedBy: string): Promise<AccountRecord>
|
||||||
|
versions(accountId: string): Promise<readonly AccountStatusVersionRow[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostStore {
|
||||||
|
insert(rec: HostRecord): Promise<void>
|
||||||
|
get(hostId: string): Promise<HostRecord | null>
|
||||||
|
getBySubdomain(subdomain: string): Promise<HostRecord | null>
|
||||||
|
listByAccount(accountId: string): Promise<readonly HostRecord[]>
|
||||||
|
/** Atomic status version + pointer swap. `revokedAt` set only on 'revoked'. Returns NEW record. */
|
||||||
|
swapStatus(
|
||||||
|
hostId: string,
|
||||||
|
status: HostStatus,
|
||||||
|
revokedAt: string | null,
|
||||||
|
changedBy: string,
|
||||||
|
): Promise<HostRecord>
|
||||||
|
/** Advisory liveness cache update — NOT versioned (INV7; live truth is Redis). */
|
||||||
|
touchLastSeen(hostId: string, ts: string): Promise<void>
|
||||||
|
versions(hostId: string): Promise<readonly HostStatusVersionRow[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionStore {
|
||||||
|
insert(rec: SessionRecord): Promise<void>
|
||||||
|
get(sessionId: string): Promise<SessionRecord | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reservation of a subdomain label; atomic single-winner under concurrency (INV1). */
|
||||||
|
export interface SubdomainStore {
|
||||||
|
reserve(subdomain: string): Promise<boolean> // false ⇒ already taken
|
||||||
|
isTaken(subdomain: string): Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairingRow {
|
||||||
|
readonly record: PairingCodeRecord
|
||||||
|
readonly redeemAttempts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RedeemOutcome =
|
||||||
|
| 'ok'
|
||||||
|
| 'already_redeemed'
|
||||||
|
| 'expired'
|
||||||
|
| 'unknown'
|
||||||
|
| 'too_many_attempts'
|
||||||
|
| 'bad_csr'
|
||||||
|
|
||||||
|
export type CasOutcome = 'ok' | 'already_redeemed' | 'unknown'
|
||||||
|
|
||||||
|
export interface PairingStore {
|
||||||
|
insert(rec: PairingCodeRecord): Promise<void>
|
||||||
|
get(codeHash: string): Promise<PairingRow | null>
|
||||||
|
/**
|
||||||
|
* Atomic compare-and-set of `redeemedAt` from null (double-spend guard). Exactly one
|
||||||
|
* concurrent caller wins 'ok'; the loser gets 'already_redeemed'. Lockout/expiry are
|
||||||
|
* pre-checked by the registry from `get()`.
|
||||||
|
*/
|
||||||
|
casRedeem(codeHash: string, nowIso: string): Promise<CasOutcome>
|
||||||
|
/** Atomic failed-attempt increment for a known code_hash; returns the new attempt count. */
|
||||||
|
registerFailure(codeHash: string): Promise<number>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redis-like routing table with per-key heartbeat TTL (INV7). */
|
||||||
|
export interface RouteStore {
|
||||||
|
set(hostId: string, entry: RouteEntry, ttlSec: number): Promise<void>
|
||||||
|
refreshTtl(hostId: string, ttlSec: number): Promise<boolean> // false if key already expired/absent
|
||||||
|
get(hostId: string): Promise<RouteEntry | null>
|
||||||
|
delete(hostId: string): Promise<void>
|
||||||
|
/** Live routes whose entry targets `nodeId` (used by drain enumeration). */
|
||||||
|
listByNode(nodeId: string): Promise<readonly { hostId: string; entry: RouteEntry }[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodeStatus = 'active' | 'draining'
|
||||||
|
export interface NodeRow {
|
||||||
|
readonly nodeId: string
|
||||||
|
readonly addr: string
|
||||||
|
readonly status: NodeStatus
|
||||||
|
readonly lastSeen: string
|
||||||
|
}
|
||||||
|
export interface NodeStore {
|
||||||
|
upsert(row: NodeRow): Promise<void>
|
||||||
|
get(nodeId: string): Promise<NodeRow | null>
|
||||||
|
setStatus(nodeId: string, status: NodeStatus): Promise<void>
|
||||||
|
touch(nodeId: string, ts: string): Promise<void>
|
||||||
|
delete(nodeId: string): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeteringStore {
|
||||||
|
append(row: MeteringSampleRow): Promise<void> // append-only (INV8)
|
||||||
|
query(accountId: string, fromIso: string, toIso: string): Promise<readonly MeteringSampleRow[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditRow {
|
||||||
|
readonly action: string
|
||||||
|
readonly principalId: string
|
||||||
|
readonly accountId: string
|
||||||
|
readonly hostId: string | null
|
||||||
|
readonly ts: string
|
||||||
|
readonly meta: Readonly<Record<string, string>>
|
||||||
|
}
|
||||||
|
export interface AuditStore {
|
||||||
|
append(row: AuditRow): Promise<void> // append-only, no update/delete path (INV10)
|
||||||
|
query(accountId: string, fromIso: string, toIso: string): Promise<readonly AuditRow[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Stores {
|
||||||
|
readonly accounts: AccountStore
|
||||||
|
readonly hosts: HostStore
|
||||||
|
readonly sessions: SessionStore
|
||||||
|
readonly subdomains: SubdomainStore
|
||||||
|
readonly pairing: PairingStore
|
||||||
|
readonly routes: RouteStore
|
||||||
|
readonly nodes: NodeStore
|
||||||
|
readonly metering: MeteringStore
|
||||||
|
readonly audit: AuditStore
|
||||||
|
}
|
||||||
95
control-plane/src/subdomain/assign.ts
Normal file
95
control-plane/src/subdomain/assign.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* T6 — per-tenant subdomain assignment. The subdomain is the browser-origin isolation boundary
|
||||||
|
* (EXPLORE §3, INV1): a malformed/duplicate label must never collapse two tenants into one
|
||||||
|
* origin. Validation is RFC-1123-label strict; assignment is an atomic single-winner reserve.
|
||||||
|
*/
|
||||||
|
import type { SubdomainStore } from '../store/ports.js'
|
||||||
|
import type { AuditWriter } from '../audit/log.js'
|
||||||
|
import { noopAuditWriter } from '../audit/log.js'
|
||||||
|
import { nowIso } from '../util/ids.js'
|
||||||
|
|
||||||
|
/** Names that must never become a tenant subdomain (host-header / infra confusion). */
|
||||||
|
export const RESERVED_SUBDOMAINS: ReadonlySet<string> = new Set([
|
||||||
|
'www',
|
||||||
|
'api',
|
||||||
|
'admin',
|
||||||
|
'app',
|
||||||
|
'relay',
|
||||||
|
'term',
|
||||||
|
'root',
|
||||||
|
'localhost',
|
||||||
|
'_',
|
||||||
|
])
|
||||||
|
|
||||||
|
const LABEL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/
|
||||||
|
|
||||||
|
/** Lowercase, strip characters invalid in an RFC-1123 label; collapse to a candidate label. */
|
||||||
|
export function normalizeSubdomain(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9-]/g, '')
|
||||||
|
.replace(/^-+/, '')
|
||||||
|
.replace(/-+$/, '')
|
||||||
|
.slice(0, 63)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1–63 chars, [a-z0-9-], not leading/trailing '-', not reserved. */
|
||||||
|
export function isValidSubdomain(raw: string): boolean {
|
||||||
|
if (raw.length < 1 || raw.length > 63) return false
|
||||||
|
if (!LABEL_RE.test(raw)) return false
|
||||||
|
if (RESERVED_SUBDOMAINS.has(raw)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 'alice' + 'term.example.com' → 'alice.term.example.com'. */
|
||||||
|
export function assembleFqdn(subdomain: string, baseDomain: string): string {
|
||||||
|
return `${subdomain}.${baseDomain}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_COLLISION_RETRIES = 5
|
||||||
|
|
||||||
|
export interface SubdomainAssigner {
|
||||||
|
assignSubdomain(accountId: string, requested?: string): Promise<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubdomainAssignerDeps {
|
||||||
|
readonly subdomains: SubdomainStore
|
||||||
|
readonly audit?: AuditWriter
|
||||||
|
readonly actor?: string
|
||||||
|
/** Deterministic suffix source (test seam); defaults to random 4-hex. */
|
||||||
|
readonly suffix?: () => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSubdomainAssigner(deps: SubdomainAssignerDeps): SubdomainAssigner {
|
||||||
|
const audit = deps.audit ?? noopAuditWriter()
|
||||||
|
const actor = deps.actor ?? 'system'
|
||||||
|
const suffix = deps.suffix ?? (() => Math.floor(Math.random() * 0xffff).toString(16).padStart(4, '0'))
|
||||||
|
return {
|
||||||
|
async assignSubdomain(accountId, requested) {
|
||||||
|
const base = requested !== undefined ? normalizeSubdomain(requested) : `h${suffix()}`
|
||||||
|
if (!isValidSubdomain(base)) {
|
||||||
|
throw new Error(`invalid subdomain: ${requested ?? base}`)
|
||||||
|
}
|
||||||
|
// A requested name that collides is NEVER silently reassigned to someone else's tenant.
|
||||||
|
// We try the exact name, then deterministic '-<suffix>' variants, else 409-equivalent throw.
|
||||||
|
const candidates = [base, ...Array.from({ length: MAX_COLLISION_RETRIES }, () => `${base}-${suffix()}`)]
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!isValidSubdomain(candidate)) continue
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
if (await deps.subdomains.reserve(candidate)) {
|
||||||
|
await audit.writeAuditEvent({
|
||||||
|
action: 'subdomain.assign',
|
||||||
|
principalId: actor,
|
||||||
|
accountId,
|
||||||
|
hostId: null,
|
||||||
|
ts: nowIso(),
|
||||||
|
meta: { subdomain: candidate },
|
||||||
|
})
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`subdomain collision: ${base} is taken (409)`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
29
control-plane/src/util/bytes.ts
Normal file
29
control-plane/src/util/bytes.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/** Byte / base64 helpers (dependency-free; Node Buffer under the hood). */
|
||||||
|
|
||||||
|
export function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
return Buffer.from(bytes).toString('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base64ToBytes(b64: string): Uint8Array {
|
||||||
|
// Buffer.from is lenient; assert round-trip to reject clearly-invalid input.
|
||||||
|
const buf = Buffer.from(b64, 'base64')
|
||||||
|
if (buf.toString('base64').replace(/=+$/, '') !== b64.replace(/=+$/, '')) {
|
||||||
|
throw new Error('invalid base64')
|
||||||
|
}
|
||||||
|
return new Uint8Array(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bytesToHex(bytes: Uint8Array): string {
|
||||||
|
return Buffer.from(bytes).toString('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Constant-time equality of two byte arrays (length-independent short-circuit avoided). */
|
||||||
|
export function timingSafeEqualBytes(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
let diff = 0
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
// Non-null via loop bounds; noUncheckedIndexedAccess guarded below.
|
||||||
|
diff |= (a[i] as number) ^ (b[i] as number)
|
||||||
|
}
|
||||||
|
return diff === 0
|
||||||
|
}
|
||||||
119
control-plane/src/util/crypto.ts
Normal file
119
control-plane/src/util/crypto.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Asymmetric crypto helpers built on Node's builtin `crypto` (no native deps).
|
||||||
|
*
|
||||||
|
* - Ed25519 raw-key <-> KeyObject bridging for CSR proof-of-possession (T8/T15 INV14) and
|
||||||
|
* the bind-time CA leaf signature.
|
||||||
|
* - X25519 seal/open (ECIES-style) for wrapping the per-host content secret to the enrolled
|
||||||
|
* identity (T8 FIX 3 INV4/INV5).
|
||||||
|
*
|
||||||
|
* NOTE (integration seam): production uses real PKCS#10 CSRs + X.509 leaves (`@peculiar/x509`)
|
||||||
|
* and converts the Ed25519 enrollment key to X25519 (ed2curve). Here the CSR is modelled as an
|
||||||
|
* Ed25519 proof-of-possession signature and the wrap targets a raw X25519 public key, which
|
||||||
|
* exercises the SAME security properties (possession proof, per-host wrap, no raw secret at rest)
|
||||||
|
* with builtin crypto only. Swapping in the X.509 stack does not change any §4 contract shape.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
createPublicKey,
|
||||||
|
createPrivateKey,
|
||||||
|
sign as edSign,
|
||||||
|
verify as edVerify,
|
||||||
|
generateKeyPairSync,
|
||||||
|
diffieHellman,
|
||||||
|
hkdfSync,
|
||||||
|
randomBytes,
|
||||||
|
createCipheriv,
|
||||||
|
createDecipheriv,
|
||||||
|
type KeyObject,
|
||||||
|
} from 'node:crypto'
|
||||||
|
|
||||||
|
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
|
||||||
|
const X25519_SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex')
|
||||||
|
|
||||||
|
/** Wrap a raw 32-byte Ed25519 public key into a verifiable KeyObject. */
|
||||||
|
export function ed25519PublicKeyFromRaw(raw: Uint8Array): KeyObject {
|
||||||
|
if (raw.length !== 32) throw new Error('ed25519 public key must be 32 bytes')
|
||||||
|
return createPublicKey({
|
||||||
|
key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(raw)]),
|
||||||
|
format: 'der',
|
||||||
|
type: 'spki',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify an Ed25519 signature over `message` by the raw public key. Never throws. */
|
||||||
|
export function ed25519Verify(rawPub: Uint8Array, message: Uint8Array, sig: Uint8Array): boolean {
|
||||||
|
try {
|
||||||
|
const key = ed25519PublicKeyFromRaw(rawPub)
|
||||||
|
return edVerify(null, Buffer.from(message), key, Buffer.from(sig))
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test/util: generate an Ed25519 keypair, returning raw pubkey + a KeyObject private key. */
|
||||||
|
export function generateEd25519(): { publicKeyRaw: Uint8Array; privateKey: KeyObject } {
|
||||||
|
const { publicKey, privateKey } = generateKeyPairSync('ed25519')
|
||||||
|
const spki = publicKey.export({ format: 'der', type: 'spki' }) as Buffer
|
||||||
|
const raw = spki.subarray(spki.length - 32)
|
||||||
|
return { publicKeyRaw: new Uint8Array(raw), privateKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test/util: sign a message with an Ed25519 private KeyObject. */
|
||||||
|
export function ed25519Sign(privateKey: KeyObject, message: Uint8Array): Uint8Array {
|
||||||
|
return new Uint8Array(edSign(null, Buffer.from(message), privateKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- X25519 wrap/unwrap (host content secret, FIX 3) --------------------------------------
|
||||||
|
|
||||||
|
export function generateX25519(): { publicKeyRaw: Uint8Array; privateKey: KeyObject } {
|
||||||
|
const { publicKey, privateKey } = generateKeyPairSync('x25519')
|
||||||
|
const spki = publicKey.export({ format: 'der', type: 'spki' }) as Buffer
|
||||||
|
const raw = spki.subarray(spki.length - 32)
|
||||||
|
return { publicKeyRaw: new Uint8Array(raw), privateKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
function x25519PublicKeyFromRaw(raw: Uint8Array): KeyObject {
|
||||||
|
if (raw.length !== 32) throw new Error('x25519 public key must be 32 bytes')
|
||||||
|
return createPublicKey({
|
||||||
|
key: Buffer.concat([X25519_SPKI_PREFIX, Buffer.from(raw)]),
|
||||||
|
format: 'der',
|
||||||
|
type: 'spki',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const WRAP_INFO = Buffer.from('relay-cp/host-content-secret/v1')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seal `secret` to a recipient X25519 public key (ephemeral-static ECDH → HKDF → AES-256-GCM).
|
||||||
|
* Output layout: ephPub(32) || iv(12) || tag(16) || ciphertext. The raw secret never appears in
|
||||||
|
* the output. A fresh ephemeral key per call makes every wrap distinct (per-host revocable).
|
||||||
|
*/
|
||||||
|
export function sealToX25519(recipientRawPub: Uint8Array, secret: Uint8Array): Uint8Array {
|
||||||
|
const recipient = x25519PublicKeyFromRaw(recipientRawPub)
|
||||||
|
const eph = generateKeyPairSync('x25519')
|
||||||
|
const ephSpki = eph.publicKey.export({ format: 'der', type: 'spki' }) as Buffer
|
||||||
|
const ephPubRaw = ephSpki.subarray(ephSpki.length - 32)
|
||||||
|
const shared = diffieHellman({ privateKey: eph.privateKey, publicKey: recipient })
|
||||||
|
const aeadKey = Buffer.from(hkdfSync('sha256', shared, ephPubRaw, WRAP_INFO, 32))
|
||||||
|
const iv = randomBytes(12)
|
||||||
|
const cipher = createCipheriv('aes-256-gcm', aeadKey, iv)
|
||||||
|
const ct = Buffer.concat([cipher.update(Buffer.from(secret)), cipher.final()])
|
||||||
|
const tag = cipher.getAuthTag()
|
||||||
|
return new Uint8Array(Buffer.concat([ephPubRaw, iv, tag, ct]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a sealed blob with the recipient's X25519 private key. Throws on tamper/wrong key. */
|
||||||
|
export function openFromX25519(recipientPriv: KeyObject, blob: Uint8Array): Uint8Array {
|
||||||
|
const buf = Buffer.from(blob)
|
||||||
|
const ephPubRaw = buf.subarray(0, 32)
|
||||||
|
const iv = buf.subarray(32, 44)
|
||||||
|
const tag = buf.subarray(44, 60)
|
||||||
|
const ct = buf.subarray(60)
|
||||||
|
const ephPub = x25519PublicKeyFromRaw(new Uint8Array(ephPubRaw))
|
||||||
|
const shared = diffieHellman({ privateKey: recipientPriv, publicKey: ephPub })
|
||||||
|
const aeadKey = Buffer.from(hkdfSync('sha256', shared, ephPubRaw, WRAP_INFO, 32))
|
||||||
|
const decipher = createDecipheriv('aes-256-gcm', aeadKey, iv)
|
||||||
|
decipher.setAuthTag(tag)
|
||||||
|
return new Uint8Array(Buffer.concat([decipher.update(ct), decipher.final()]))
|
||||||
|
}
|
||||||
|
|
||||||
|
export { createPrivateKey, type KeyObject }
|
||||||
44
control-plane/src/util/hash.ts
Normal file
44
control-plane/src/util/hash.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Secret hashing at rest (INV5). Pairing codes and v0.8 tokens are stored as salted scrypt
|
||||||
|
* hashes, never raw. Verification is constant-time. scrypt is a Node builtin (no native dep);
|
||||||
|
* argon2id would be the production upgrade (integration note) but scrypt satisfies INV5 here.
|
||||||
|
*/
|
||||||
|
import { randomBytes, scryptSync, timingSafeEqual, createHash } from 'node:crypto'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic hash for high-entropy lookup keys (pairing codes: ≥128-bit input, so a fast
|
||||||
|
* preimage-resistant hash is safe at rest — the input space is infeasible to brute-force, INV5).
|
||||||
|
* A salted scrypt cannot be used here because redemption must find the row by hash of the
|
||||||
|
* presented code. Production may HMAC this with a server-side pepper (integration note).
|
||||||
|
*/
|
||||||
|
export function sha256Hex(raw: string): string {
|
||||||
|
return createHash('sha256').update(raw, 'utf8').digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCRYPT_KEYLEN = 32
|
||||||
|
const SCRYPT_COST = 1 << 14 // N=16384 — modest, fast enough for tests, memory-hard
|
||||||
|
const SALT_LEN = 16
|
||||||
|
|
||||||
|
/** Produce a self-describing `scrypt$<saltHex>$<hashHex>` string. Raw secret is discarded. */
|
||||||
|
export function hashSecret(raw: string): string {
|
||||||
|
const salt = randomBytes(SALT_LEN)
|
||||||
|
const hash = scryptSync(raw, salt, SCRYPT_KEYLEN, { N: SCRYPT_COST })
|
||||||
|
return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Constant-time verify a raw secret against a stored `scrypt$salt$hash`. */
|
||||||
|
export function verifySecret(raw: string, stored: string): boolean {
|
||||||
|
const parts = stored.split('$')
|
||||||
|
if (parts.length !== 3 || parts[0] !== 'scrypt') return false
|
||||||
|
const saltHex = parts[1] as string
|
||||||
|
const hashHex = parts[2] as string
|
||||||
|
let expected: Buffer
|
||||||
|
try {
|
||||||
|
expected = Buffer.from(hashHex, 'hex')
|
||||||
|
const salt = Buffer.from(saltHex, 'hex')
|
||||||
|
const actual = scryptSync(raw, salt, expected.length, { N: SCRYPT_COST })
|
||||||
|
return timingSafeEqual(actual, expected)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
10
control-plane/src/util/ids.ts
Normal file
10
control-plane/src/util/ids.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/** Identifier + timestamp helpers. UUIDv4 host/account/session ids are unguessable (INV1). */
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
|
||||||
|
export function newUuid(): string {
|
||||||
|
return randomUUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nowIso(): string {
|
||||||
|
return new Date().toISOString()
|
||||||
|
}
|
||||||
102
control-plane/test/api-extra.test.ts
Normal file
102
control-plane/test/api-extra.test.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, test, expect, beforeEach } from 'vitest'
|
||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import { createMemoryStores } from '../src/store/memory.js'
|
||||||
|
import { buildControlPlane } from '../src/main.js'
|
||||||
|
import { loadEnv } from '../src/env.js'
|
||||||
|
import { generateEd25519 } from '../src/util/crypto.js'
|
||||||
|
import { buildCsr } from '../src/ca/csr.js'
|
||||||
|
import { bytesToBase64 } from '../src/util/bytes.js'
|
||||||
|
import { nodeIdentityFromRequest, type RequestWithTls } from '../src/node-auth/identity.js'
|
||||||
|
import type { CapabilityVerifier } from '../src/api/authz.js'
|
||||||
|
import type { CapabilityToken, CapabilityRight } from 'relay-contracts'
|
||||||
|
import type { Stores } from '../src/store/ports.js'
|
||||||
|
|
||||||
|
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
|
||||||
|
|
||||||
|
const env = loadEnv({
|
||||||
|
PG_URL: 'postgres://u:p@localhost:5432/cp',
|
||||||
|
REDIS_URL: 'redis://localhost:6379',
|
||||||
|
CAPABILITY_SIGN_PUBKEY_B64: bytesToBase64(new Uint8Array(32).fill(1)),
|
||||||
|
CA_INTERMEDIATE_KMS_KEY_REF: 'kms://key/intermediate',
|
||||||
|
CA_INTERMEDIATE_CERT_PATH: '/etc/cp/int.pem',
|
||||||
|
NODE_MTLS_TRUST_BUNDLE_PATH: '/etc/cp/node-ca.pem',
|
||||||
|
BASE_DOMAIN: 'term.example.com',
|
||||||
|
})
|
||||||
|
|
||||||
|
const verifier: CapabilityVerifier = {
|
||||||
|
verify(raw, expectedAud, now): 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' }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
let app: FastifyInstance
|
||||||
|
let stores: Stores
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
stores = createMemoryStores()
|
||||||
|
const built = await buildControlPlane(env, { stores, verifier })
|
||||||
|
app = built.app
|
||||||
|
await app.ready()
|
||||||
|
})
|
||||||
|
|
||||||
|
const auth = { authorization: 'Bearer tokenA' }
|
||||||
|
|
||||||
|
describe('T11 provisioning API — remaining routes', () => {
|
||||||
|
test('POST /accounts creates an account (201)', async () => {
|
||||||
|
const res = await app.inject({ method: 'POST', url: '/accounts', headers: auth, payload: { plan: 'pro' } })
|
||||||
|
expect(res.statusCode).toBe(201)
|
||||||
|
expect(JSON.parse(res.body).plan).toBe('pro')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST /accounts/:id/status suspends own account', async () => {
|
||||||
|
// must be the caller's own account id (A) — create A explicitly in store
|
||||||
|
const { createAccountRegistry } = await import('../src/registry/accounts.js')
|
||||||
|
const reg = createAccountRegistry({ accounts: stores.accounts })
|
||||||
|
await stores.accounts.insert({ accountId: ACCOUNT_A, plan: 'free', createdAt: new Date().toISOString(), status: 'active' })
|
||||||
|
void reg
|
||||||
|
const res = await app.inject({ method: 'POST', url: `/accounts/${ACCOUNT_A}/status`, headers: auth, payload: { status: 'suspended' } })
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
expect(JSON.parse(res.body).status).toBe('suspended')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GET /accounts/:id/hosts returns ownership-scoped list', async () => {
|
||||||
|
const res = await app.inject({ method: 'GET', url: `/accounts/${ACCOUNT_A}/hosts`, headers: auth })
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
expect(Array.isArray(JSON.parse(res.body))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('end-to-end enroll: issue code then POST /enroll → 201 EnrollResult', async () => {
|
||||||
|
const issued = await app.inject({ method: 'POST', url: `/accounts/${ACCOUNT_A}/pairing-codes`, headers: auth })
|
||||||
|
const code = JSON.parse(issued.body).code as string
|
||||||
|
const { publicKeyRaw, privateKey } = generateEd25519()
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/enroll',
|
||||||
|
payload: {
|
||||||
|
code,
|
||||||
|
agentPubkey: bytesToBase64(publicKeyRaw),
|
||||||
|
csr: bytesToBase64(buildCsr(privateKey, publicKeyRaw)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(201)
|
||||||
|
const body = JSON.parse(res.body)
|
||||||
|
expect(body.subdomain).toBeDefined()
|
||||||
|
expect(body.cert).toContain('BEGIN CERTIFICATE')
|
||||||
|
expect(typeof body.hostContentSecret).toBe('string')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('T9 nodeIdentityFromRequest wrapper', () => {
|
||||||
|
test('derives nodeId from an authorized TLS peer cert subject CN', () => {
|
||||||
|
const req: RequestWithTls = {
|
||||||
|
socket: { authorized: true, getPeerCertificate: () => ({ subject: { CN: 'spiffe://relay/node-7' } }) },
|
||||||
|
}
|
||||||
|
expect(nodeIdentityFromRequest(req).nodeId).toBe('spiffe://relay/node-7')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unauthenticated socket → 401', () => {
|
||||||
|
const req: RequestWithTls = { socket: { authorized: false, getPeerCertificate: () => undefined } }
|
||||||
|
expect(() => nodeIdentityFromRequest(req)).toThrow(/mutually authenticated/)
|
||||||
|
})
|
||||||
|
})
|
||||||
96
control-plane/test/api.test.ts
Normal file
96
control-plane/test/api.test.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, test, expect, beforeEach } from 'vitest'
|
||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import { createMemoryStores } from '../src/store/memory.js'
|
||||||
|
import { createHostRegistry } from '../src/registry/hosts.js'
|
||||||
|
import { buildControlPlane } from '../src/main.js'
|
||||||
|
import { loadEnv } from '../src/env.js'
|
||||||
|
import { fingerprint } from '../src/ca/fingerprint.js'
|
||||||
|
import { generateEd25519 } from '../src/util/crypto.js'
|
||||||
|
import { bytesToBase64 } from '../src/util/bytes.js'
|
||||||
|
import type { CapabilityVerifier } from '../src/api/authz.js'
|
||||||
|
import type { CapabilityToken, CapabilityRight } from 'relay-contracts'
|
||||||
|
import type { Stores } from '../src/store/ports.js'
|
||||||
|
|
||||||
|
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
|
||||||
|
const ACCOUNT_B = '22222222-2222-4222-8222-222222222222'
|
||||||
|
|
||||||
|
const env = loadEnv({
|
||||||
|
PG_URL: 'postgres://u:p@localhost:5432/cp',
|
||||||
|
REDIS_URL: 'redis://localhost:6379',
|
||||||
|
CAPABILITY_SIGN_PUBKEY_B64: bytesToBase64(new Uint8Array(32).fill(1)),
|
||||||
|
CA_INTERMEDIATE_KMS_KEY_REF: 'kms://key/intermediate',
|
||||||
|
CA_INTERMEDIATE_CERT_PATH: '/etc/cp/int.pem',
|
||||||
|
NODE_MTLS_TRUST_BUNDLE_PATH: '/etc/cp/node-ca.pem',
|
||||||
|
BASE_DOMAIN: 'term.example.com',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fake P5 verifier: 'tokenA'→account A (manage), 'attachA'→account A (attach only). Else reject.
|
||||||
|
const verifier: CapabilityVerifier = {
|
||||||
|
verify(raw, expectedAud, now): 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[] }
|
||||||
|
throw new Error('invalid token')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
let app: FastifyInstance
|
||||||
|
let stores: Stores
|
||||||
|
let hostOfB: string
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
stores = createMemoryStores()
|
||||||
|
const built = await buildControlPlane(env, { stores, verifier })
|
||||||
|
app = built.app
|
||||||
|
await app.ready()
|
||||||
|
// Bind a host owned by account B directly.
|
||||||
|
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||||
|
const { publicKeyRaw } = generateEd25519()
|
||||||
|
const host = await hosts.bindHost({ accountId: ACCOUNT_B, subdomain: 'bob', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
|
||||||
|
hostOfB = host.hostId
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('T11 provisioning API (INV3/INV1/INV6)', () => {
|
||||||
|
test('HEADLINE: account A with forged {account_id:B} deleting B-owned host → 403', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/hosts/${hostOfB}`,
|
||||||
|
headers: { authorization: 'Bearer tokenA' },
|
||||||
|
payload: { account_id: ACCOUNT_B }, // forged — must be ignored
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('missing token → 401', async () => {
|
||||||
|
const res = await app.inject({ method: 'DELETE', url: `/hosts/${hostOfB}` })
|
||||||
|
expect(res.statusCode).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('attach-scoped token cannot hit a manage route → 403', async () => {
|
||||||
|
const res = await app.inject({ method: 'DELETE', url: `/hosts/${hostOfB}`, headers: { authorization: 'Bearer attachA' } })
|
||||||
|
expect(res.statusCode).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('owner CAN deprovision its own host → 204', async () => {
|
||||||
|
// give A its own host
|
||||||
|
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||||
|
const { publicKeyRaw } = generateEd25519()
|
||||||
|
const own = await hosts.bindHost({ accountId: ACCOUNT_A, subdomain: 'alice', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
|
||||||
|
const res = await app.inject({ method: 'DELETE', url: `/hosts/${own.hostId}`, headers: { authorization: 'Bearer tokenA' } })
|
||||||
|
expect(res.statusCode).toBe(204)
|
||||||
|
expect((await stores.hosts.get(own.hostId))?.status).toBe('revoked')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pairing-code route scoped to own account; foreign :id → 403', async () => {
|
||||||
|
const ok = await app.inject({ method: 'POST', url: `/accounts/${ACCOUNT_A}/pairing-codes`, headers: { authorization: 'Bearer tokenA' } })
|
||||||
|
expect(ok.statusCode).toBe(201)
|
||||||
|
expect(JSON.parse(ok.body).code).toBeDefined()
|
||||||
|
const foreign = await app.inject({ method: 'POST', url: `/accounts/${ACCOUNT_B}/pairing-codes`, headers: { authorization: 'Bearer tokenA' } })
|
||||||
|
expect(foreign.statusCode).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('malformed enroll body → 400 (Zod at the boundary)', async () => {
|
||||||
|
const res = await app.inject({ method: 'POST', url: '/enroll', payload: { code: '' } })
|
||||||
|
expect(res.statusCode).toBe(400)
|
||||||
|
})
|
||||||
|
})
|
||||||
61
control-plane/test/audit.test.ts
Normal file
61
control-plane/test/audit.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, test, expect } from 'vitest'
|
||||||
|
import { createMemoryStores } from '../src/store/memory.js'
|
||||||
|
import { createAuditLog, MAX_META_VALUE_LEN } from '../src/audit/log.js'
|
||||||
|
import { createAccountRegistry } from '../src/registry/accounts.js'
|
||||||
|
import { createHostRegistry } from '../src/registry/hosts.js'
|
||||||
|
import { fingerprint } from '../src/ca/fingerprint.js'
|
||||||
|
import { generateEd25519 } from '../src/util/crypto.js'
|
||||||
|
|
||||||
|
describe('T14 audit log (INV10 zero payload)', () => {
|
||||||
|
test('rejects a meta value carrying control/ANSI bytes (terminal payload guard)', async () => {
|
||||||
|
const { audit } = createMemoryStores()
|
||||||
|
const log = createAuditLog(audit)
|
||||||
|
await expect(
|
||||||
|
log.writeAuditEvent({
|
||||||
|
action: 'attach',
|
||||||
|
principalId: 'p',
|
||||||
|
accountId: 'a',
|
||||||
|
hostId: null,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
meta: { out: 'ls[0m\r\noutput' }, // looks like shell output
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/payload guard/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects an over-long meta value', async () => {
|
||||||
|
const { audit } = createMemoryStores()
|
||||||
|
const log = createAuditLog(audit)
|
||||||
|
await expect(
|
||||||
|
log.writeAuditEvent({
|
||||||
|
action: 'manage',
|
||||||
|
principalId: 'p',
|
||||||
|
accountId: 'a',
|
||||||
|
hostId: null,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
meta: { blob: 'x'.repeat(MAX_META_VALUE_LEN + 1) },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/payload guard/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('append-only: entries are queryable, no update/delete surface exists', async () => {
|
||||||
|
const { audit } = createMemoryStores()
|
||||||
|
const log = createAuditLog(audit)
|
||||||
|
await log.writeAuditEvent({ action: 'kill', principalId: 'p', accountId: 'acct', hostId: 'h', ts: new Date().toISOString(), meta: {} })
|
||||||
|
const rows = await log.queryAudit('acct', '1970-01-01T00:00:00.000Z', '2999-01-01T00:00:00.000Z')
|
||||||
|
expect(rows.length).toBe(1)
|
||||||
|
expect(Object.keys(log)).not.toContain('update')
|
||||||
|
expect(Object.keys(log)).not.toContain('delete')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('control-plane mutations emit audit entries (create + bind)', async () => {
|
||||||
|
const stores = createMemoryStores()
|
||||||
|
const log = createAuditLog(stores.audit)
|
||||||
|
const accounts = createAccountRegistry({ accounts: stores.accounts, audit: log })
|
||||||
|
const hosts = createHostRegistry({ hosts: stores.hosts, audit: log })
|
||||||
|
const acct = await accounts.createAccount('pro')
|
||||||
|
const { publicKeyRaw } = generateEd25519()
|
||||||
|
await hosts.bindHost({ accountId: acct.accountId, subdomain: 'alice', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
|
||||||
|
const rows = await log.queryAudit(acct.accountId, '1970-01-01T00:00:00.000Z', '2999-01-01T00:00:00.000Z')
|
||||||
|
expect(rows.map((r) => r.action).sort()).toEqual(['account.create', 'host.bind'])
|
||||||
|
})
|
||||||
|
})
|
||||||
73
control-plane/test/ca.test.ts
Normal file
73
control-plane/test/ca.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, test, expect, vi } from 'vitest'
|
||||||
|
import { createMemoryStores } from '../src/store/memory.js'
|
||||||
|
import { createHostRegistry } from '../src/registry/hosts.js'
|
||||||
|
import { createLeafSigner, LeafSignError } from '../src/ca/sign.js'
|
||||||
|
import { inProcessCaSigner } from '../src/boot/ca-wiring.js'
|
||||||
|
import { buildCsr, verifyCsrPoP } from '../src/ca/csr.js'
|
||||||
|
import { fingerprint } from '../src/ca/fingerprint.js'
|
||||||
|
import { generateEd25519, sealToX25519, openFromX25519, generateX25519 } from '../src/util/crypto.js'
|
||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
|
||||||
|
function boundHost() {
|
||||||
|
const stores = createMemoryStores()
|
||||||
|
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||||
|
return { stores, hosts }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('T8 signHostLeaf — INV14 registry-gated + CSR PoP', () => {
|
||||||
|
test('active registered host + valid CSR → cert signed under CA', async () => {
|
||||||
|
const { stores, hosts } = boundHost()
|
||||||
|
const { publicKeyRaw, privateKey } = generateEd25519()
|
||||||
|
const host = await hosts.bindHost({ accountId: '11111111-1111-4111-8111-111111111111', subdomain: 'alice', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
|
||||||
|
const signer = createLeafSigner({ hosts: stores.hosts, signer: inProcessCaSigner(), caChainDer: [] })
|
||||||
|
const { cert } = await signer.signHostLeaf(host.hostId, publicKeyRaw, buildCsr(privateKey, publicKeyRaw))
|
||||||
|
expect(cert.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('CSR proof-of-possession fails even for a registered active key → reject, KMS never called', async () => {
|
||||||
|
const { stores, hosts } = boundHost()
|
||||||
|
const { publicKeyRaw } = generateEd25519()
|
||||||
|
const host = await hosts.bindHost({ accountId: '11111111-1111-4111-8111-111111111111', subdomain: 'alice', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
|
||||||
|
const spy = inProcessCaSigner()
|
||||||
|
const signSpy = vi.spyOn(spy, 'sign')
|
||||||
|
const signer = createLeafSigner({ hosts: stores.hosts, signer: spy, caChainDer: [] })
|
||||||
|
// forge a CSR: correct embedded pub but a garbage signature
|
||||||
|
const forged = new Uint8Array(96)
|
||||||
|
forged.set(publicKeyRaw, 0)
|
||||||
|
forged.set(randomBytes(64), 32)
|
||||||
|
expect(verifyCsrPoP(forged).ok).toBe(false)
|
||||||
|
await expect(signer.signHostLeaf(host.hostId, publicKeyRaw, forged)).rejects.toBeInstanceOf(LeafSignError)
|
||||||
|
expect(signSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('INV14 gate: unregistered pubkey → throws, KMS sign never invoked', async () => {
|
||||||
|
const { stores } = boundHost()
|
||||||
|
const { publicKeyRaw, privateKey } = generateEd25519()
|
||||||
|
const spy = inProcessCaSigner()
|
||||||
|
const signSpy = vi.spyOn(spy, 'sign')
|
||||||
|
const signer = createLeafSigner({ hosts: stores.hosts, signer: spy, caChainDer: [] })
|
||||||
|
await expect(signer.signHostLeaf('00000000-0000-4000-8000-000000000000', publicKeyRaw, buildCsr(privateKey, publicKeyRaw))).rejects.toBeInstanceOf(LeafSignError)
|
||||||
|
expect(signSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('revoked host cannot be signed (INV12+INV14)', async () => {
|
||||||
|
const { stores, hosts } = boundHost()
|
||||||
|
const { publicKeyRaw, privateKey } = generateEd25519()
|
||||||
|
const host = await hosts.bindHost({ accountId: '11111111-1111-4111-8111-111111111111', subdomain: 'alice', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
|
||||||
|
await hosts.setHostStatus(host.hostId, 'revoked')
|
||||||
|
const signer = createLeafSigner({ hosts: stores.hosts, signer: inProcessCaSigner(), caChainDer: [] })
|
||||||
|
await expect(signer.signHostLeaf(host.hostId, publicKeyRaw, buildCsr(privateKey, publicKeyRaw))).rejects.toBeInstanceOf(LeafSignError)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('host content-secret wrap (FIX 3 security property)', () => {
|
||||||
|
test('only the matching private key can unwrap; raw secret absent from blob', () => {
|
||||||
|
const recipient = generateX25519()
|
||||||
|
const secret = new Uint8Array(randomBytes(32))
|
||||||
|
const blob = sealToX25519(recipient.publicKeyRaw, secret)
|
||||||
|
expect(Buffer.from(blob).includes(Buffer.from(secret))).toBe(false) // raw secret not in ciphertext
|
||||||
|
expect(Buffer.from(openFromX25519(recipient.privateKey, blob)).equals(Buffer.from(secret))).toBe(true)
|
||||||
|
const wrong = generateX25519()
|
||||||
|
expect(() => openFromX25519(wrong.privateKey, blob)).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
21
control-plane/test/db.test.ts
Normal file
21
control-plane/test/db.test.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, test, expect } from 'vitest'
|
||||||
|
import { createQuery, type Queryable } from '../src/db/pool.js'
|
||||||
|
|
||||||
|
describe('T2 parameterized query wrapper (no SQLi path)', () => {
|
||||||
|
test('passes params SEPARATELY from SQL text — never interpolated', async () => {
|
||||||
|
const calls: { text: string; params: readonly unknown[] }[] = []
|
||||||
|
const fake: Queryable = {
|
||||||
|
async query(text, params) {
|
||||||
|
calls.push({ text, params })
|
||||||
|
return { rows: [{ ok: 1 }] }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const query = createQuery(fake)
|
||||||
|
const rows = await query<{ ok: number }>('SELECT * FROM hosts WHERE host_id = $1', ["'; DROP TABLE hosts; --"])
|
||||||
|
expect(rows).toEqual([{ ok: 1 }])
|
||||||
|
expect(calls[0]?.text).toBe('SELECT * FROM hosts WHERE host_id = $1')
|
||||||
|
// The malicious value stays a bound param — it is NOT concatenated into the SQL text.
|
||||||
|
expect(calls[0]?.text).not.toContain('DROP TABLE')
|
||||||
|
expect(calls[0]?.params).toEqual(["'; DROP TABLE hosts; --"])
|
||||||
|
})
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user