feat(relay-run): Phase 0 single-host relay — real data-plane wiring + integration test

New relay-run/ package boots the REAL term-relay relay-node between a browser WSS
listener and an agent mTLS listener, delegating every authz verdict to the real
relay-auth onUpgrade (Origin/CSWSH + capability verify + DPoP PoP + single-use jti),
with the real P4 E2E crypto. No audited package modified.

- P1 (done): in-process integration test round-trips a sealed payload both ways
  through the real createRelayNode splice + real createMuxSession; INV2 asserted
  (relay sees only ciphertext); negative controls (foreign Origin / missing DPoP -> 401).
- P2 (boots): main.ts brings up self-signed TLS + 3 listeners + in-memory control-plane.
- P3 (not landed): no agent dial / node-pty / relay-web serve yet.
- 4 tests green, tsc clean. node_modules via symlinks (gitignored).

Two real seam drifts surfaced (documented in PLAN_RELAY_RUN_PHASE0, not hacked):
(1) term-relay authz-port DpopContext shape vs relay-auth — reconciled (server-derived htu/htm);
(2) control-plane CapabilityVerifier.verify is sync but relay-auth verify is async — blocks
    HTTP account/pairing seeding until a 1-line async widen.
This commit is contained in:
Yaojia Wang
2026-07-04 14:13:04 +02:00
parent 3d17bed322
commit ba85871227
17 changed files with 1239 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
/**
* Dev-only self-signed TLS material, generated at boot via the system `openssl`. NEVER for
* production: a throwaway CA + a browser server cert + an agent-facing server cert signed by that
* CA. Private keys are written 0600 and never logged (INV9). The dev CA must never be reused
* anywhere real.
*/
import { execFileSync } from 'node:child_process'
import { mkdtempSync, readFileSync, chmodSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
export interface DevCerts {
readonly dir: string
readonly browserCertPath: string
readonly browserKeyPath: string
readonly agentServerCertPath: string
readonly agentServerKeyPath: string
readonly caCertPath: string
readonly caCertPem: string
cleanup(): void
}
function openssl(args: readonly string[], cwd: string): void {
execFileSync('openssl', args, { cwd, stdio: ['ignore', 'ignore', 'pipe'] })
}
/**
* Generate the dev PKI. Uses Ed25519 keys (OpenSSL 3). Throws if `openssl` is unavailable or a
* step fails — the caller fails fast rather than booting without TLS.
*/
export function generateDevCerts(browserCn: string): DevCerts {
const dir = mkdtempSync(join(tmpdir(), 'relay-run-certs-'))
const p = (name: string): string => join(dir, name)
// Throwaway CA (agent mTLS trust anchor).
openssl(
['req', '-x509', '-newkey', 'ed25519', '-keyout', 'ca.key', '-out', 'ca.crt', '-days', '7',
'-nodes', '-subj', '/CN=relay-run-dev-ca'],
dir,
)
// Browser-facing server cert (self-signed; the user accepts it once).
openssl(
['req', '-x509', '-newkey', 'ed25519', '-keyout', 'browser.key', '-out', 'browser.crt',
'-days', '7', '-nodes', '-subj', `/CN=${browserCn}`,
'-addext', `subjectAltName=DNS:${browserCn},DNS:localhost,IP:127.0.0.1`],
dir,
)
// Agent-facing server cert, signed by the dev CA (the agent pins this CA).
openssl(
['req', '-new', '-newkey', 'ed25519', '-keyout', 'agent-server.key', '-out', 'agent-server.csr',
'-nodes', '-subj', '/CN=localhost',
'-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1'],
dir,
)
openssl(
['x509', '-req', '-in', 'agent-server.csr', '-CA', 'ca.crt', '-CAkey', 'ca.key',
'-CAcreateserial', '-out', 'agent-server.crt', '-days', '7', '-copy_extensions', 'copy'],
dir,
)
for (const key of ['ca.key', 'browser.key', 'agent-server.key']) chmodSync(p(key), 0o600)
return {
dir,
browserCertPath: p('browser.crt'),
browserKeyPath: p('browser.key'),
agentServerCertPath: p('agent-server.crt'),
agentServerKeyPath: p('agent-server.key'),
caCertPath: p('ca.crt'),
caCertPem: readFileSync(p('ca.crt'), 'utf8'),
cleanup() {
rmSync(dir, { recursive: true, force: true })
},
}
}