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:
16
relay-run/package.json
Normal file
16
relay-run/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "relay-run",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Phase 0 single-host runnable rendezvous-relay. Composes the REAL P1 (term-relay data-plane), P4 (relay-e2e), and P5 (relay-auth) exports through in-memory control-plane stores + self-signed TLS, so a browser can click through to a local shell E2E-spliced by the real relay-node. Dev only. See docs/PLAN_RELAY_RUN_PHASE0.md.",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "tsx src/main.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
77
relay-run/src/certs/dev-certs.ts
Normal file
77
relay-run/src/certs/dev-certs.ts
Normal 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 })
|
||||
},
|
||||
}
|
||||
}
|
||||
135
relay-run/src/main.ts
Normal file
135
relay-run/src/main.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Phase 0 live process — `npm start`. Boots the single-host rendezvous-relay with REAL security:
|
||||
* • self-signed TLS (dev CA + browser + agent-facing certs),
|
||||
* • the REAL relay-node data plane spliced between a browser WSS listener (:8443) and an agent
|
||||
* mTLS listener (:8444) — every upgrade runs the real Origin/CSWSH + capability verify + DPoP
|
||||
* path (P5 `onUpgrade`),
|
||||
* • an in-memory control-plane (:8080, best-effort).
|
||||
* Prints a ready URL + a freshly-minted browser capability token + the tenant subdomain.
|
||||
*
|
||||
* A FULL browser click-through is NOT expected in Phase 0: browsers can't attach the DPoP header
|
||||
* to a WS upgrade, and no agent tunnel is dialed here yet (P3). This process proves the servers
|
||||
* boot and the authz/splice pipeline is wired end to end.
|
||||
*/
|
||||
import { createAuthorizer } from './wiring/authorizer.js'
|
||||
import { buildDataPlane, makeDataPlaneConfig } from './wiring/data-plane.js'
|
||||
import { buildPhase0World } from './wiring/relay-world.js'
|
||||
import { generateDevCerts } from './certs/dev-certs.js'
|
||||
import { makeAgentTlsServerFactory } from './servers/agent-tls.js'
|
||||
import { startBrowserServer } from './servers/browser-server.js'
|
||||
import { bootControlPlane } from './servers/control-plane-boot.js'
|
||||
|
||||
const BIND_HOST = '127.0.0.1'
|
||||
const BROWSER_PORT = 8443
|
||||
const AGENT_PORT = 8444
|
||||
const CONTROL_PLANE_PORT = 8080
|
||||
// Capability tokens are deliberately short-lived (relay-auth caps connect TTL at 60s).
|
||||
const TOKEN_TTL_SEC = 60
|
||||
|
||||
function landingHtml(subdomain: string): string {
|
||||
return `<!doctype html><meta charset="utf-8"><title>relay-run (Phase 0)</title>
|
||||
<h1>rendezvous-relay · Phase 0 (dev)</h1>
|
||||
<p>Tenant subdomain: <code>${subdomain}</code></p>
|
||||
<p>This dev relay boots the REAL data-plane splice + P5 authz. A full terminal click-through needs
|
||||
a dialed agent tunnel and the browser DPoP transport (Phase 3).</p>`
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const now = () => Math.floor(Date.now() / 1000)
|
||||
const world = await buildPhase0World(now())
|
||||
const subdomain = world.host.subdomain
|
||||
const browserCn = `${subdomain}.${world.baseDomain}`
|
||||
|
||||
const certs = generateDevCerts(browserCn)
|
||||
|
||||
// A real browser's Origin includes the listener port, so the CSWSH allow-list must too
|
||||
// (the world's default origin is port-less for the in-process test).
|
||||
const allowedOrigins = [`https://${browserCn}:${BROWSER_PORT}`, ...world.allowedOrigins]
|
||||
|
||||
// Real-time authz (token exp / DPoP freshness advance with the wall clock).
|
||||
const authorizer = createAuthorizer({
|
||||
deps: world.deps,
|
||||
allowedOrigins,
|
||||
now,
|
||||
})
|
||||
|
||||
const config = makeDataPlaneConfig({
|
||||
baseDomain: world.baseDomain,
|
||||
bindHost: BIND_HOST,
|
||||
bindPort: BROWSER_PORT,
|
||||
agentBindPort: AGENT_PORT,
|
||||
})
|
||||
|
||||
// Boots the agent-facing mTLS server as part of the listener construction.
|
||||
const dp = buildDataPlane({
|
||||
config,
|
||||
authorizer,
|
||||
resolver: world.resolver,
|
||||
mtls: world.mtls,
|
||||
now,
|
||||
caBundle: [Buffer.from(certs.caCertPem)],
|
||||
onError: (e) => console.error('[data-plane]', e),
|
||||
tlsServerFactory: makeAgentTlsServerFactory({
|
||||
serverCertPath: certs.agentServerCertPath,
|
||||
serverKeyPath: certs.agentServerKeyPath,
|
||||
bindHost: BIND_HOST,
|
||||
bindPort: AGENT_PORT,
|
||||
onListening: () => console.log(`[agent-mtls] listening wss://${BIND_HOST}:${AGENT_PORT}`),
|
||||
onError: (e) => console.error('[agent-mtls]', e),
|
||||
}),
|
||||
})
|
||||
|
||||
const browserServer = startBrowserServer({
|
||||
certPath: certs.browserCertPath,
|
||||
keyPath: certs.browserKeyPath,
|
||||
bindHost: BIND_HOST,
|
||||
bindPort: BROWSER_PORT,
|
||||
node: dp.node,
|
||||
landingHtml: landingHtml(subdomain),
|
||||
onListening: () => console.log(`[browser-wss] listening https://${BIND_HOST}:${BROWSER_PORT}`),
|
||||
onError: (e) => console.error('[browser-wss]', e),
|
||||
})
|
||||
|
||||
let controlPlane: { close(): Promise<void> } | null = null
|
||||
try {
|
||||
controlPlane = await bootControlPlane({
|
||||
publicRaw: world.publicRaw,
|
||||
baseDomain: world.baseDomain,
|
||||
caCertPath: certs.caCertPath,
|
||||
bindHost: BIND_HOST,
|
||||
bindPort: CONTROL_PLANE_PORT,
|
||||
})
|
||||
console.log(`[control-plane] listening http://${BIND_HOST}:${CONTROL_PLANE_PORT} (admin authz fail-closed — sync-verify seam)`)
|
||||
} catch (e) {
|
||||
console.warn('[control-plane] boot skipped:', e instanceof Error ? e.message : e)
|
||||
}
|
||||
|
||||
// Mint a demo browser capability token bound to a fresh DPoP key (the raw token is a bearer
|
||||
// secret — dev only; never log real tokens).
|
||||
const cap = await world.issueCap({ now: now(), ttl: TOKEN_TTL_SEC })
|
||||
|
||||
console.log('\n=== relay-run Phase 0 READY ===')
|
||||
console.log(`Origin : https://${browserCn}:${BROWSER_PORT}`)
|
||||
console.log(`Add to /etc/hosts: 127.0.0.1 ${browserCn}`)
|
||||
console.log(`Subdomain : ${subdomain} hostId: ${world.host.hostId}`)
|
||||
console.log(`Browser cap : ${cap.raw}`)
|
||||
console.log(`DPoP proof : ${cap.dpop.proofJws}`)
|
||||
console.log('Note: no agent tunnel is dialed yet → browser upgrades resolve but 1013 (agent offline).')
|
||||
console.log('Ctrl-C to stop.\n')
|
||||
|
||||
const shutdown = async (): Promise<void> => {
|
||||
console.log('\nshutting down…')
|
||||
browserServer.close()
|
||||
dp.listener.close()
|
||||
if (controlPlane) await controlPlane.close().catch(() => {})
|
||||
certs.cleanup()
|
||||
process.exit(0)
|
||||
}
|
||||
process.on('SIGINT', () => void shutdown())
|
||||
process.on('SIGTERM', () => void shutdown())
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('fatal:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
49
relay-run/src/servers/agent-tls.ts
Normal file
49
relay-run/src/servers/agent-tls.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Real agent-facing mTLS server for `main.ts`. A `TlsServerFactory` that stands up an https server
|
||||
* (`requestCert: true`, `rejectUnauthorized: true`, pinned dev CA) with a `ws` WebSocketServer over
|
||||
* it, so an agent dials `wss://` under mTLS. The TLS layer rejects a no-cert / non-chaining peer
|
||||
* BEFORE `attach()` runs (Finding-4 two-tier defense); `verifyPeer` (P5) then binds the cert to the
|
||||
* registry. Every connection's DER cert is handed to the relay's `onPeer`.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createServer } from 'node:https'
|
||||
import type { TLSSocket } from 'node:tls'
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
import { WebSocketServer, type WebSocket as WsWebSocket } from 'ws'
|
||||
import type { TlsServerFactory, TlsServerLike } from 'term-relay/data-plane/agent-listener.js'
|
||||
import { wsToWebSocketLike } from '../wiring/socket-pipe.js'
|
||||
|
||||
export interface AgentTlsOptions {
|
||||
readonly serverCertPath: string
|
||||
readonly serverKeyPath: string
|
||||
readonly bindHost: string
|
||||
readonly bindPort: number
|
||||
readonly onListening?: () => void
|
||||
readonly onError?: (e: unknown) => void
|
||||
}
|
||||
|
||||
export function makeAgentTlsServerFactory(opts: AgentTlsOptions): TlsServerFactory {
|
||||
return (tlsOpts, onPeer): TlsServerLike => {
|
||||
const server = createServer({
|
||||
cert: readFileSync(opts.serverCertPath),
|
||||
key: readFileSync(opts.serverKeyPath),
|
||||
ca: tlsOpts.ca.map((b) => Buffer.from(b)),
|
||||
requestCert: tlsOpts.requestCert,
|
||||
rejectUnauthorized: tlsOpts.rejectUnauthorized,
|
||||
})
|
||||
const wss = new WebSocketServer({ server })
|
||||
wss.on('connection', (ws: WsWebSocket, req: IncomingMessage) => {
|
||||
const peer = (req.socket as TLSSocket).getPeerCertificate(true)
|
||||
const der = peer && peer.raw ? new Uint8Array(peer.raw) : new Uint8Array()
|
||||
onPeer(wsToWebSocketLike(ws), der)
|
||||
})
|
||||
server.on('error', (e) => opts.onError?.(e))
|
||||
server.listen(opts.bindPort, opts.bindHost, () => opts.onListening?.())
|
||||
return {
|
||||
close: () => {
|
||||
wss.close()
|
||||
server.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
77
relay-run/src/servers/browser-server.ts
Normal file
77
relay-run/src/servers/browser-server.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Browser-facing WSS server for `main.ts`. Terminates HTTPS on the tenant origin and, on WS
|
||||
* upgrade, builds the P1 `UpgradeRequest` from the raw request headers and hands it to the REAL
|
||||
* relay-node (→ real `authorizeUpgrade` → P5 `onUpgrade`: Origin/CSWSH + capability verify + DPoP).
|
||||
* The handshake echoes ONLY `APP_SUBPROTOCOL` — never the token entry (INV15).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createServer, type Server } from 'node:https'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { WebSocketServer, type WebSocket as WsWebSocket } from 'ws'
|
||||
import { APP_SUBPROTOCOL } from 'relay-contracts'
|
||||
import type { UpgradeRequest } from 'term-relay/data-plane/upgrade.js'
|
||||
import type { RelayNode } from 'term-relay/data-plane/relay-node.js'
|
||||
import { wsToWebSocketLike } from '../wiring/socket-pipe.js'
|
||||
|
||||
export interface BrowserServerOptions {
|
||||
readonly certPath: string
|
||||
readonly keyPath: string
|
||||
readonly bindHost: string
|
||||
readonly bindPort: number
|
||||
readonly node: RelayNode
|
||||
readonly landingHtml: string
|
||||
readonly onListening?: () => void
|
||||
readonly onError?: (e: unknown) => void
|
||||
}
|
||||
|
||||
function parseCookies(header: string | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
if (!header) return out
|
||||
for (const part of header.split(';')) {
|
||||
const idx = part.indexOf('=')
|
||||
if (idx === -1) continue
|
||||
out[part.slice(0, idx).trim()] = part.slice(idx + 1).trim()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function buildUpgradeRequest(req: IncomingMessage): UpgradeRequest {
|
||||
const proto = req.headers['sec-websocket-protocol']
|
||||
const subprotocols = (typeof proto === 'string' ? proto.split(',') : [])
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0)
|
||||
const dpopHeader = req.headers['dpop']
|
||||
return {
|
||||
host: req.headers.host ?? '',
|
||||
origin: typeof req.headers.origin === 'string' ? req.headers.origin : undefined,
|
||||
url: req.url ?? '/',
|
||||
subprotocols,
|
||||
cookies: parseCookies(req.headers.cookie),
|
||||
remoteAddr: req.socket.remoteAddress ?? '',
|
||||
dpop: { proof: typeof dpopHeader === 'string' ? dpopHeader : null, publicKeyThumbprint: null },
|
||||
activeSessionCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function startBrowserServer(opts: BrowserServerOptions): Server {
|
||||
const server = createServer(
|
||||
{ cert: readFileSync(opts.certPath), key: readFileSync(opts.keyPath) },
|
||||
(_req: IncomingMessage, res: ServerResponse) => {
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
||||
res.end(opts.landingHtml)
|
||||
},
|
||||
)
|
||||
const wss = new WebSocketServer({
|
||||
server,
|
||||
handleProtocols: (protocols: Set<string>) =>
|
||||
protocols.has(APP_SUBPROTOCOL) ? APP_SUBPROTOCOL : false,
|
||||
})
|
||||
wss.on('connection', (ws: WsWebSocket, req: IncomingMessage) => {
|
||||
opts.node
|
||||
.handleBrowserUpgrade(buildUpgradeRequest(req), wsToWebSocketLike(ws))
|
||||
.catch((e) => opts.onError?.(e))
|
||||
})
|
||||
server.on('error', (e) => opts.onError?.(e))
|
||||
server.listen(opts.bindPort, opts.bindHost, () => opts.onListening?.())
|
||||
return server
|
||||
}
|
||||
41
relay-run/src/servers/control-plane-boot.ts
Normal file
41
relay-run/src/servers/control-plane-boot.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Best-effort control-plane (P3) boot for `main.ts`: in-memory stores + the dev in-process CA
|
||||
* signer, listening for admin/enroll HTTP. Wrapped so a failure degrades gracefully (the data-plane
|
||||
* is the security-critical surface and boots independently).
|
||||
*
|
||||
* KNOWN SEAM (reported, not hacked): P3's injected `CapabilityVerifier.verify` is SYNCHRONOUS, but
|
||||
* relay-auth's real `verifyCapabilityToken` is ASYNC (WebCrypto). The real P5 verifier therefore
|
||||
* cannot be injected without an additive change to control-plane, so the admin routes run on the
|
||||
* default fail-closed verifier here (deny-by-default, never bypassed). Programmatic account/pairing
|
||||
* seeding through those routes is consequently BLOCKED in Phase 0 — see the report.
|
||||
*/
|
||||
import { buildControlPlane } from 'control-plane'
|
||||
import { loadEnv } from 'control-plane/src/env.js'
|
||||
import { createMemoryStores } from 'control-plane/src/store/memory.js'
|
||||
|
||||
export interface ControlPlaneBootOptions {
|
||||
readonly publicRaw: Uint8Array
|
||||
readonly baseDomain: string
|
||||
readonly caCertPath: string
|
||||
readonly bindHost: string
|
||||
readonly bindPort: number
|
||||
}
|
||||
|
||||
export interface ControlPlaneHandle {
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
export async function bootControlPlane(opts: ControlPlaneBootOptions): Promise<ControlPlaneHandle> {
|
||||
const env = loadEnv({
|
||||
PG_URL: 'postgres://localhost:5432/relay_run_dev',
|
||||
REDIS_URL: 'redis://localhost:6379',
|
||||
CAPABILITY_SIGN_PUBKEY_B64: Buffer.from(opts.publicRaw).toString('base64'),
|
||||
CA_INTERMEDIATE_KMS_KEY_REF: 'dev-inprocess-signer',
|
||||
CA_INTERMEDIATE_CERT_PATH: opts.caCertPath,
|
||||
NODE_MTLS_TRUST_BUNDLE_PATH: opts.caCertPath,
|
||||
BASE_DOMAIN: opts.baseDomain,
|
||||
})
|
||||
const { app } = await buildControlPlane(env, { stores: createMemoryStores() })
|
||||
await app.listen({ port: opts.bindPort, host: opts.bindHost })
|
||||
return { close: () => app.close() }
|
||||
}
|
||||
85
relay-run/src/wiring/authorizer.ts
Normal file
85
relay-run/src/wiring/authorizer.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Authorizer adapter — implements the term-relay (P1) `Authorizer` port by DELEGATING every verdict
|
||||
* to the REAL relay-auth (P5) `onUpgrade`/`onReattach`. P1 holds no authz logic; this file only
|
||||
* reconciles two structurally-drifted-but-equivalent seam shapes:
|
||||
*
|
||||
* 1. DpopContext. P1's port carries `{ proof, publicKeyThumbprint }`; P5 needs `{ proofJws, htu,
|
||||
* htm }`. The relay derives `htu`/`htm` from the request's OWN authority (`expectedAud` = the
|
||||
* resolved subdomain), never from client-supplied claims — the spec-faithful DPoP binding.
|
||||
* 2. AuthzOutcome. P5's `principal` is a full AuthenticatedPrincipal; P1's port only needs a
|
||||
* string, so we surface `principal.accountId` (P1's adapter reads only `hostId`/`jti`).
|
||||
*
|
||||
* F2 (step-up principal wiring): Phase 0 uses a not-required step-up policy, so P5 resolves identity
|
||||
* from the signed token (INV3) and a null session-principal is correct. `resolvePrincipal` is the
|
||||
* seam a later phase injects to pass an authenticated principal for step-up-required hosts.
|
||||
*/
|
||||
import {
|
||||
onUpgrade as p5OnUpgrade,
|
||||
onReattach as p5OnReattach,
|
||||
type EnforceDeps,
|
||||
type AuthenticatedPrincipal,
|
||||
type AuthzOutcome as P5AuthzOutcome,
|
||||
} from 'relay-auth'
|
||||
import type {
|
||||
Authorizer,
|
||||
UpgradeContext as PortUpgradeContext,
|
||||
AuthzOutcome as PortAuthzOutcome,
|
||||
} from 'term-relay/data-plane/authz-port.js'
|
||||
import { DPOP_HTM, htuFor } from './capability.js'
|
||||
|
||||
export interface AuthorizerDeps {
|
||||
readonly deps: EnforceDeps
|
||||
readonly allowedOrigins: readonly string[]
|
||||
readonly now: () => number
|
||||
/** F2 seam: resolve the authenticated session principal for step-up. Phase 0 default → null. */
|
||||
readonly resolvePrincipal?: (ctx: PortUpgradeContext) => AuthenticatedPrincipal | null
|
||||
}
|
||||
|
||||
function toP5Context(
|
||||
ctx: PortUpgradeContext,
|
||||
resolvePrincipal: (ctx: PortUpgradeContext) => AuthenticatedPrincipal | null,
|
||||
) {
|
||||
return {
|
||||
capabilityRaw: ctx.capabilityRaw,
|
||||
originHeader: ctx.originHeader,
|
||||
expectedAud: ctx.expectedAud,
|
||||
requestedHostId: ctx.requestedHostId,
|
||||
requiredRight: ctx.requiredRight,
|
||||
remoteAddrHash: ctx.remoteAddrHash,
|
||||
activeSessionCount: ctx.activeSessionCount,
|
||||
// Reconstruct the full DPoP context; htu/htm come from the relay's authority, not the client.
|
||||
dpop: { proofJws: ctx.dpop.proof ?? '', htu: htuFor(ctx.expectedAud), htm: DPOP_HTM },
|
||||
principal: resolvePrincipal(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
function toPortOutcome(outcome: P5AuthzOutcome): PortAuthzOutcome {
|
||||
return outcome.ok
|
||||
? { ok: true, hostId: outcome.hostId, principal: outcome.principal.accountId, jti: outcome.jti }
|
||||
: { ok: false, status: outcome.status }
|
||||
}
|
||||
|
||||
/** Build the P1 `Authorizer` port backed by the real P5 enforcement pipeline. */
|
||||
export function createAuthorizer(deps: AuthorizerDeps): Authorizer {
|
||||
const resolvePrincipal = deps.resolvePrincipal ?? (() => null)
|
||||
return {
|
||||
async onUpgrade(ctx, now) {
|
||||
const outcome = await p5OnUpgrade(
|
||||
toP5Context(ctx, resolvePrincipal),
|
||||
deps.deps,
|
||||
deps.allowedOrigins,
|
||||
now,
|
||||
)
|
||||
return toPortOutcome(outcome)
|
||||
},
|
||||
async onReattach(ctx, now) {
|
||||
const outcome = await p5OnReattach(
|
||||
{ ...toP5Context(ctx, resolvePrincipal), sessionId: ctx.sessionId },
|
||||
deps.deps,
|
||||
deps.allowedOrigins,
|
||||
now,
|
||||
)
|
||||
return toPortOutcome(outcome)
|
||||
},
|
||||
}
|
||||
}
|
||||
111
relay-run/src/wiring/capability.ts
Normal file
111
relay-run/src/wiring/capability.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* P5 signing-key setup + capability-token / DPoP bundle builder for Phase 0. Wires the REAL
|
||||
* relay-auth issue + DPoP primitives (deep-imported test/internal helpers — relay-auth has no
|
||||
* `exports` map, so subpath imports resolve through the relay-run node_modules symlink). Lifted
|
||||
* from `e2e/harness/dpop.ts`.
|
||||
*
|
||||
* The DPoP `htu`/`htm` are LOAD-BEARING: the browser signs them into the proof and the relay
|
||||
* re-derives them from the request's own authority (never from client claims). `htuFor` is the
|
||||
* SINGLE source of truth used by BOTH sides so issuance and verification agree.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { issueCapabilityToken, type DpopContext } from 'relay-auth'
|
||||
import type { CapabilityRight } from 'relay-contracts'
|
||||
import {
|
||||
configureVerifyKey,
|
||||
resetVerifyKeyForTest,
|
||||
} from 'relay-auth/src/config/keys.js'
|
||||
import {
|
||||
generateEd25519KeyPair,
|
||||
exportEd25519PublicRaw,
|
||||
} from 'relay-auth/src/crypto/ed25519.js'
|
||||
import { buildDpopProof, resetDpopCacheForTest } from 'relay-auth/src/capability/verify.js'
|
||||
import { jwkThumbprint } from 'relay-auth/src/crypto/thumbprint.js'
|
||||
import { principal } from './memory-stores.js'
|
||||
|
||||
export { resetDpopCacheForTest }
|
||||
|
||||
/** DPoP HTTP method the browser binds into every proof (WS upgrade is a GET). */
|
||||
export const DPOP_HTM = 'GET' as const
|
||||
|
||||
/**
|
||||
* Canonical DPoP `htu` for a subdomain/aud. The relay derives this from the resolved subdomain
|
||||
* (its own authority) and the browser signs the identical value — the ONE place the string is
|
||||
* defined, so the two never drift.
|
||||
*/
|
||||
export function htuFor(aud: string): string {
|
||||
return `https://${aud}/ws`
|
||||
}
|
||||
|
||||
export interface P5Key {
|
||||
readonly signingKey: CryptoKey
|
||||
readonly publicRaw: Uint8Array
|
||||
}
|
||||
|
||||
/** Configure the P5 verifying key globally and return the matching private signing key. */
|
||||
export async function setupP5SigningKey(): Promise<P5Key> {
|
||||
resetVerifyKeyForTest()
|
||||
const pair = await generateEd25519KeyPair()
|
||||
configureVerifyKey(pair.publicKey)
|
||||
const publicRaw = await exportEd25519PublicRaw(pair.publicKey)
|
||||
return { signingKey: pair.privateKey, publicRaw }
|
||||
}
|
||||
|
||||
interface Ephemeral {
|
||||
readonly privateKey: CryptoKey
|
||||
readonly publicRaw: Uint8Array
|
||||
}
|
||||
|
||||
async function makeEphemeral(): Promise<Ephemeral> {
|
||||
const pair = await generateEd25519KeyPair()
|
||||
return { privateKey: pair.privateKey, publicRaw: await exportEd25519PublicRaw(pair.publicKey) }
|
||||
}
|
||||
|
||||
export interface IssueOpts {
|
||||
readonly accountId: string
|
||||
readonly host: string
|
||||
readonly aud: string
|
||||
readonly rights?: readonly CapabilityRight[]
|
||||
readonly ttl?: number
|
||||
readonly now: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A capability token bundled with a matching DPoP proof. `newDpop(at?)` mints a FRESH DPoP proof
|
||||
* (new jti) bound to the SAME ephemeral key — needed to re-present the same token under a distinct
|
||||
* DPoP without tripping the DPoP replay cache.
|
||||
*/
|
||||
export interface CapBundle {
|
||||
readonly raw: string
|
||||
readonly dpop: DpopContext
|
||||
readonly newDpop: (at?: number) => Promise<DpopContext>
|
||||
}
|
||||
|
||||
export async function issueCapBundle(signingKey: CryptoKey, o: IssueOpts): Promise<CapBundle> {
|
||||
const eph = await makeEphemeral()
|
||||
const cnfJkt = await jwkThumbprint(eph.publicRaw)
|
||||
const raw = await issueCapabilityToken(
|
||||
{
|
||||
principal: principal(o.accountId),
|
||||
aud: o.aud,
|
||||
host: o.host,
|
||||
rights: o.rights ?? ['attach'],
|
||||
ttlSeconds: o.ttl ?? 45,
|
||||
cnfJkt,
|
||||
},
|
||||
signingKey,
|
||||
o.now,
|
||||
)
|
||||
const htu = htuFor(o.aud)
|
||||
const newDpop = async (at: number = o.now): Promise<DpopContext> => ({
|
||||
proofJws: await buildDpopProof(eph.privateKey, eph.publicRaw, {
|
||||
htu,
|
||||
htm: DPOP_HTM,
|
||||
jti: randomUUID(),
|
||||
iat: at,
|
||||
}),
|
||||
htu,
|
||||
htm: DPOP_HTM,
|
||||
})
|
||||
return { raw, dpop: await newDpop(o.now), newDpop }
|
||||
}
|
||||
119
relay-run/src/wiring/data-plane.ts
Normal file
119
relay-run/src/wiring/data-plane.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Data-plane composition — assembles the REAL term-relay (P1) `createRelayNode` +
|
||||
* `createAgentListener` and routes every browser upgrade through the REAL `authorizeUpgrade`
|
||||
* adapter (→ our P5 `Authorizer`). The relay-node performs the OPAQUE byte splice (INV2): it never
|
||||
* decodes DATA. This is the one composition both the integration test and `main.ts` reuse.
|
||||
*/
|
||||
import { createRelayNode, type RelayNode } from 'term-relay/data-plane/relay-node.js'
|
||||
import {
|
||||
createAgentListener,
|
||||
type AgentListener,
|
||||
type AgentTunnel,
|
||||
type MtlsVerifier,
|
||||
type RouteRegistrar,
|
||||
type TlsServerFactory,
|
||||
} from 'term-relay/data-plane/agent-listener.js'
|
||||
import {
|
||||
authorizeUpgrade,
|
||||
type UpgradeRequest,
|
||||
type UpgradeDecision,
|
||||
} from 'term-relay/data-plane/upgrade.js'
|
||||
import type { RouteResolver } from 'term-relay/data-plane/subdomain-router.js'
|
||||
import type { DataPlaneConfig } from 'term-relay/data-plane/config.js'
|
||||
import type { Authorizer } from 'term-relay/data-plane/authz-port.js'
|
||||
import type { CapabilityRight } from 'relay-contracts'
|
||||
import type { ScheduleHandle } from 'term-relay/mux/heartbeat.js'
|
||||
|
||||
const RELAY_NODE_ID = 'relay-run-node-0'
|
||||
const REMOTE_ADDR_SALT = 'relay-run-dev-salt'
|
||||
|
||||
/** Build a frozen DataPlaneConfig without touching disk (cert paths are informational here — the
|
||||
* live TLS servers are constructed by main.ts, not from these fields). */
|
||||
export function makeDataPlaneConfig(overrides: Partial<DataPlaneConfig> = {}): DataPlaneConfig {
|
||||
return Object.freeze({
|
||||
baseDomain: 'term.localhost',
|
||||
bindHost: '127.0.0.1',
|
||||
bindPort: 8443,
|
||||
agentBindPort: 8444,
|
||||
tlsCertPath: '(in-memory)',
|
||||
tlsKeyPath: '(in-memory)',
|
||||
agentCaCertPath: '(in-memory)',
|
||||
agentCaChainPath: '(in-memory)',
|
||||
relayNodeId: RELAY_NODE_ID,
|
||||
maxFrameBytes: 1024 * 1024,
|
||||
initialWindowBytes: 256 * 1024,
|
||||
heartbeatIntervalMs: 15_000,
|
||||
routeTtlMs: 45_000,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
/** A no-op route registrar (Phase 0 has no Redis routing table; routes live in the listener's Map). */
|
||||
export function noopRegistrar(): RouteRegistrar {
|
||||
return {
|
||||
register: async () => {},
|
||||
heartbeat: async () => {},
|
||||
deregister: async () => {},
|
||||
}
|
||||
}
|
||||
|
||||
export interface DataPlaneDeps {
|
||||
readonly config: DataPlaneConfig
|
||||
readonly authorizer: Authorizer
|
||||
readonly resolver: RouteResolver
|
||||
readonly mtls: MtlsVerifier
|
||||
readonly now: () => number
|
||||
readonly requiredRight?: CapabilityRight
|
||||
readonly caBundle?: readonly Uint8Array[]
|
||||
readonly registrar?: RouteRegistrar
|
||||
readonly onTunnel?: (t: AgentTunnel) => void
|
||||
/** Inject a manual scheduler to keep heartbeat timers out of unit tests. */
|
||||
readonly schedule?: (fn: () => void, ms: number) => ScheduleHandle
|
||||
readonly onError?: (e: unknown) => void
|
||||
/** Real agent-facing mTLS server factory (main.ts). Omitted in tests → no TLS server started. */
|
||||
readonly tlsServerFactory?: TlsServerFactory
|
||||
}
|
||||
|
||||
export interface DataPlane {
|
||||
readonly node: RelayNode
|
||||
readonly listener: AgentListener
|
||||
authorize(req: UpgradeRequest): Promise<UpgradeDecision>
|
||||
}
|
||||
|
||||
export function buildDataPlane(deps: DataPlaneDeps): DataPlane {
|
||||
const listener = createAgentListener({
|
||||
config: deps.config,
|
||||
mtls: deps.mtls,
|
||||
registrar: deps.registrar ?? noopRegistrar(),
|
||||
onTunnel: deps.onTunnel ?? (() => {}),
|
||||
caBundle: deps.caBundle ?? [],
|
||||
...(deps.tlsServerFactory ? { tlsServerFactory: deps.tlsServerFactory } : {}),
|
||||
...(deps.schedule ? { schedule: deps.schedule } : {}),
|
||||
...(deps.onError ? { onError: deps.onError } : {}),
|
||||
})
|
||||
|
||||
const authorize = (req: UpgradeRequest): Promise<UpgradeDecision> =>
|
||||
authorizeUpgrade(req, {
|
||||
authorizer: deps.authorizer,
|
||||
resolver: deps.resolver,
|
||||
baseDomain: deps.config.baseDomain,
|
||||
now: deps.now,
|
||||
remoteAddrSalt: REMOTE_ADDR_SALT,
|
||||
requiredRight: deps.requiredRight ?? 'attach',
|
||||
})
|
||||
|
||||
const node = createRelayNode({ config: deps.config, listener, authorize })
|
||||
return { node, listener, authorize }
|
||||
}
|
||||
|
||||
/** In-memory RouteResolver: subdomain label → resolved host (P3's job in production). */
|
||||
export function memoryRouteResolver(
|
||||
hosts: ReadonlyMap<string, { hostId: string; accountId: string }>,
|
||||
): RouteResolver {
|
||||
return {
|
||||
resolveSubdomain: async (subdomain) => {
|
||||
const h = hosts.get(subdomain)
|
||||
return h ? { hostId: h.hostId, accountId: h.accountId, subdomain } : null
|
||||
},
|
||||
}
|
||||
}
|
||||
134
relay-run/src/wiring/memory-stores.ts
Normal file
134
relay-run/src/wiring/memory-stores.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Phase 0 in-memory control-plane stores — the ONLY seams that stand in for the true I/O
|
||||
* boundaries P1/P3 own (host registry / session registry / revocation store / token buckets /
|
||||
* audit sink / revocation bus). Every security DECISION still runs through the REAL relay-auth
|
||||
* exports; these fakes only supply storage. Lifted verbatim from `e2e/harness/fakes.ts` — the
|
||||
* reference wiring named in docs/PLAN_RELAY_RUN_PHASE0.md.
|
||||
*/
|
||||
import type { HostRecord, KillSignal, RevocationBus } from 'relay-contracts'
|
||||
import type {
|
||||
AuthenticatedPrincipal,
|
||||
AuditEvent,
|
||||
AuditSink,
|
||||
HostRegistryPort,
|
||||
SessionRegistryPort,
|
||||
RevocationStore,
|
||||
TokenBucketStore,
|
||||
} from 'relay-auth'
|
||||
|
||||
/** An authenticated principal (the ONLY source of accountId, INV3). */
|
||||
export function principal(
|
||||
accountId: string,
|
||||
overrides: Partial<AuthenticatedPrincipal> = {},
|
||||
): AuthenticatedPrincipal {
|
||||
return {
|
||||
kind: 'human',
|
||||
accountId,
|
||||
principalId: 'cred-' + accountId,
|
||||
amr: ['passkey'],
|
||||
authAt: 1000,
|
||||
stepUpAt: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** A minimal online HostRecord for the authz registry (agentPubkey here is unused by authz). */
|
||||
export function makeHostRecord(
|
||||
accountId: string,
|
||||
hostId: string,
|
||||
subdomain: string,
|
||||
status: HostRecord['status'] = 'online',
|
||||
): HostRecord {
|
||||
return {
|
||||
hostId,
|
||||
accountId,
|
||||
subdomain,
|
||||
agentPubkey: new Uint8Array(32),
|
||||
enrollFpr: 'fpr-' + hostId,
|
||||
status,
|
||||
lastSeen: '2026-01-01T00:00:00.000Z',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
revokedAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function fakeHostRegistry(hosts: readonly HostRecord[]): HostRegistryPort {
|
||||
const map = new Map(hosts.map((h) => [h.hostId, h]))
|
||||
return { getById: async (id) => map.get(id) ?? null }
|
||||
}
|
||||
|
||||
export interface MutableSessionRegistry extends SessionRegistryPort {
|
||||
add(entry: { sessionId: string; hostId: string; accountId: string }): void
|
||||
}
|
||||
|
||||
export function fakeSessionRegistry(
|
||||
seed: readonly { sessionId: string; hostId: string; accountId: string }[] = [],
|
||||
): MutableSessionRegistry {
|
||||
const map = new Map(seed.map((s) => [s.sessionId, { hostId: s.hostId, accountId: s.accountId }]))
|
||||
return {
|
||||
getById: async (id) => map.get(id) ?? null,
|
||||
add: (e) => {
|
||||
map.set(e.sessionId, { hostId: e.hostId, accountId: e.accountId })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface RevocationFake extends RevocationStore {
|
||||
readonly revoked: Set<string>
|
||||
readonly consumed: Set<string>
|
||||
}
|
||||
|
||||
export function fakeRevocationStore(): RevocationFake {
|
||||
const revoked = new Set<string>()
|
||||
const consumed = new Set<string>()
|
||||
return {
|
||||
revoked,
|
||||
consumed,
|
||||
isRevoked: async (jti) => revoked.has(jti),
|
||||
revokeJti: async (jti) => {
|
||||
revoked.add(jti)
|
||||
},
|
||||
consumeOnce: async (jti) => {
|
||||
if (consumed.has(jti)) return false
|
||||
consumed.add(jti)
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface TokenBucketFake extends TokenBucketStore {
|
||||
readonly blocked: Set<string>
|
||||
readonly calls: string[]
|
||||
}
|
||||
|
||||
/** Token bucket that always allows unless a key is added to `blocked`. */
|
||||
export function fakeTokenBucket(): TokenBucketFake {
|
||||
const blocked = new Set<string>()
|
||||
const calls: string[] = []
|
||||
return {
|
||||
blocked,
|
||||
calls,
|
||||
take: async (key) => {
|
||||
calls.push(key)
|
||||
return !blocked.has(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuditFake extends AuditSink {
|
||||
readonly events: AuditEvent[]
|
||||
}
|
||||
|
||||
export function fakeAuditSink(): AuditFake {
|
||||
const events: AuditEvent[] = []
|
||||
return { events, append: async (e) => void events.push(e) }
|
||||
}
|
||||
|
||||
export interface RevocationBusFake extends RevocationBus {
|
||||
readonly published: KillSignal[]
|
||||
}
|
||||
|
||||
export function fakeRevocationBus(): RevocationBusFake {
|
||||
const published: KillSignal[] = []
|
||||
return { published, publish: async (s) => void published.push(s) }
|
||||
}
|
||||
212
relay-run/src/wiring/relay-world.ts
Normal file
212
relay-run/src/wiring/relay-world.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Phase 0 world — composes the REAL relay-auth (P5) enforcement + relay-e2e (P4) handshake through
|
||||
* the in-memory stores, tailored for the live data-plane path: a host's capability-token `aud` is
|
||||
* the SINGLE subdomain label (what `authorizeUpgrade` derives from the Host header), not the FQDN.
|
||||
* Directly modelled on `e2e/harness/world.ts` (the reference wiring named in the build spec).
|
||||
*
|
||||
* Only the true I/O boundaries are faked; every security check (Origin/CSWSH, token verify, DPoP
|
||||
* PoP, cross-tenant gate, single-use jti) is the production relay-auth path.
|
||||
*/
|
||||
import { randomUUID, randomBytes } from 'node:crypto'
|
||||
import type { AeadAlg, E2ESession, HostHello } from 'relay-contracts'
|
||||
import {
|
||||
createClientHandshake,
|
||||
createHostHandshake,
|
||||
createE2ESession,
|
||||
MemoryDevicePinStore,
|
||||
} from 'relay-e2e'
|
||||
import {
|
||||
signDeviceAuthProof,
|
||||
verifyDeviceProof,
|
||||
type EnforceDeps,
|
||||
type StepUpPolicy,
|
||||
} from 'relay-auth'
|
||||
import {
|
||||
generateEd25519KeyPair,
|
||||
exportEd25519PublicRaw,
|
||||
importEd25519PublicRaw,
|
||||
signEd25519,
|
||||
verifyEd25519,
|
||||
} from 'relay-auth/src/crypto/ed25519.js'
|
||||
import type { MtlsVerifier } from 'term-relay/data-plane/agent-listener.js'
|
||||
import type { Authorizer } from 'term-relay/data-plane/authz-port.js'
|
||||
import type { RouteResolver } from 'term-relay/data-plane/subdomain-router.js'
|
||||
import {
|
||||
fakeAuditSink,
|
||||
fakeHostRegistry,
|
||||
fakeRevocationStore,
|
||||
fakeSessionRegistry,
|
||||
fakeTokenBucket,
|
||||
makeHostRecord,
|
||||
principal,
|
||||
type AuditFake,
|
||||
type MutableSessionRegistry,
|
||||
type RevocationFake,
|
||||
type TokenBucketFake,
|
||||
} from './memory-stores.js'
|
||||
import { issueCapBundle, setupP5SigningKey, resetDpopCacheForTest, type CapBundle } from './capability.js'
|
||||
import { createAuthorizer } from './authorizer.js'
|
||||
import { memoryRouteResolver } from './data-plane.js'
|
||||
|
||||
export const DEFAULT_NOW = 1_700_000_000
|
||||
const REPLAY_ALG: AeadAlg = 'xchacha20-poly1305'
|
||||
|
||||
/** Phase 0 default: step-up never required (F2: null session-principal is correct). */
|
||||
export const NO_STEP_UP: StepUpPolicy = {
|
||||
required: false,
|
||||
maxAgeSeconds: Number.MAX_SAFE_INTEGER,
|
||||
requiredMethod: 'passkey',
|
||||
}
|
||||
|
||||
/** A seeded host: authz identity (hostId/accountId/label) + §4.4 e2e identity + replay secret. */
|
||||
export interface HostFixture {
|
||||
readonly label: string
|
||||
readonly hostId: string
|
||||
readonly accountId: string
|
||||
readonly subdomain: string
|
||||
readonly origin: string
|
||||
readonly agentPubkey: Uint8Array
|
||||
readonly hostContentSecret: Uint8Array
|
||||
readonly replayAlg: AeadAlg
|
||||
readonly signingPriv: CryptoKey
|
||||
}
|
||||
|
||||
async function makeHostFixture(
|
||||
label: string,
|
||||
accountId: string,
|
||||
baseDomain: string,
|
||||
): Promise<HostFixture> {
|
||||
const kp = await generateEd25519KeyPair()
|
||||
const agentPubkey = await exportEd25519PublicRaw(kp.publicKey)
|
||||
return {
|
||||
label,
|
||||
hostId: randomUUID(),
|
||||
accountId,
|
||||
subdomain: label,
|
||||
origin: `https://${label}.${baseDomain}`,
|
||||
agentPubkey,
|
||||
hostContentSecret: new Uint8Array(randomBytes(32)),
|
||||
replayAlg: REPLAY_ALG,
|
||||
signingPriv: kp.privateKey,
|
||||
}
|
||||
}
|
||||
|
||||
export interface EstablishedSessions {
|
||||
readonly client: E2ESession
|
||||
readonly host: E2ESession
|
||||
readonly agentPubkey: Uint8Array
|
||||
}
|
||||
|
||||
export interface Phase0World {
|
||||
readonly now: number
|
||||
readonly signingKey: CryptoKey
|
||||
/** Raw 32-byte Ed25519 P5 verify pubkey (for CAPABILITY_SIGN_PUBKEY_B64). */
|
||||
readonly publicRaw: Uint8Array
|
||||
readonly baseDomain: string
|
||||
readonly host: HostFixture
|
||||
readonly allowedOrigins: readonly string[]
|
||||
readonly deps: EnforceDeps
|
||||
readonly audit: AuditFake
|
||||
readonly revocation: RevocationFake
|
||||
readonly buckets: TokenBucketFake
|
||||
readonly sessions: MutableSessionRegistry
|
||||
readonly resolver: RouteResolver
|
||||
readonly authorizer: Authorizer
|
||||
readonly mtls: MtlsVerifier
|
||||
issueCap(o?: { ttl?: number; now?: number }): Promise<CapBundle>
|
||||
establishSession(opts?: {
|
||||
now?: number
|
||||
verifyAgainstPubkey?: Uint8Array
|
||||
tamperHostHello?: (h: HostHello) => HostHello
|
||||
}): Promise<EstablishedSessions>
|
||||
}
|
||||
|
||||
export async function buildPhase0World(now: number = DEFAULT_NOW): Promise<Phase0World> {
|
||||
const baseDomain = 'term.localhost'
|
||||
const { signingKey, publicRaw } = await setupP5SigningKey()
|
||||
resetDpopCacheForTest()
|
||||
|
||||
const host = await makeHostFixture('alice', 'acct-alice', baseDomain)
|
||||
const hosts = fakeHostRegistry([makeHostRecord(host.accountId, host.hostId, host.subdomain)])
|
||||
const sessions = fakeSessionRegistry([])
|
||||
const revocation = fakeRevocationStore()
|
||||
const buckets = fakeTokenBucket()
|
||||
const audit = fakeAuditSink()
|
||||
const allowedOrigins = [host.origin]
|
||||
|
||||
const deps: EnforceDeps = {
|
||||
hosts,
|
||||
sessions,
|
||||
revocation,
|
||||
buckets,
|
||||
audit,
|
||||
stepUpPolicyFor: () => NO_STEP_UP,
|
||||
}
|
||||
|
||||
const routeMap = new Map([[host.subdomain, { hostId: host.hostId, accountId: host.accountId }]])
|
||||
const resolver = memoryRouteResolver(routeMap)
|
||||
const authorizer = createAuthorizer({ deps, allowedOrigins, now: () => now })
|
||||
const mtls: MtlsVerifier = {
|
||||
verifyPeer: () => ({ hostId: host.hostId, accountId: host.accountId }),
|
||||
}
|
||||
|
||||
return {
|
||||
now,
|
||||
signingKey,
|
||||
publicRaw,
|
||||
baseDomain,
|
||||
host,
|
||||
allowedOrigins,
|
||||
deps,
|
||||
audit,
|
||||
revocation,
|
||||
buckets,
|
||||
sessions,
|
||||
resolver,
|
||||
authorizer,
|
||||
mtls,
|
||||
issueCap(o = {}) {
|
||||
return issueCapBundle(signingKey, {
|
||||
accountId: host.accountId,
|
||||
host: host.hostId,
|
||||
aud: host.subdomain,
|
||||
ttl: o.ttl,
|
||||
now: o.now ?? now,
|
||||
})
|
||||
},
|
||||
async establishSession(opts = {}): Promise<EstablishedSessions> {
|
||||
const hsNow = opts.now ?? now
|
||||
const client = createClientHandshake({
|
||||
aeadOffer: ['xchacha20-poly1305', 'aes-256-gcm'],
|
||||
deviceAuthProofProvider: {
|
||||
proofFor: (_hostId, binding) =>
|
||||
signDeviceAuthProof(principal(host.accountId), binding, signingKey, hsNow),
|
||||
},
|
||||
verifier: {
|
||||
verify: async (pub, transcript, sig) =>
|
||||
verifyEd25519(await importEd25519PublicRaw(pub), sig, transcript),
|
||||
},
|
||||
pinStore: new MemoryDevicePinStore(),
|
||||
hostId: host.hostId,
|
||||
})
|
||||
const hostHs = createHostHandshake({
|
||||
signer: { sign: (transcript) => signEd25519(host.signingPriv, transcript) },
|
||||
agentPubkey: host.agentPubkey,
|
||||
supported: ['xchacha20-poly1305', 'aes-256-gcm'],
|
||||
verifyDeviceProof: (proof, binding) => verifyDeviceProof(proof, binding, hsNow),
|
||||
})
|
||||
const clientHello = await client.start()
|
||||
const rawHostHello = await hostHs.onClientHello(clientHello)
|
||||
const hostHello = opts.tamperHostHello ? opts.tamperHostHello(rawHostHello) : rawHostHello
|
||||
const clientResult = await client.onHostHello(
|
||||
hostHello,
|
||||
opts.verifyAgainstPubkey ?? host.agentPubkey,
|
||||
)
|
||||
return {
|
||||
client: createE2ESession('client', clientResult),
|
||||
host: createE2ESession('host', hostHs.result!),
|
||||
agentPubkey: host.agentPubkey,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
104
relay-run/src/wiring/socket-pipe.ts
Normal file
104
relay-run/src/wiring/socket-pipe.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* `WebSocketLike` adapters. Two of them:
|
||||
* - `makeSocketPair()` — an in-process duplex pair used to drive the REAL relay-node/mux in tests
|
||||
* (no sockets, deterministic). Delivery is synchronous but non-reentrant (a per-endpoint
|
||||
* trampoline) so a send emitted while draining the peer's inbox can't recurse unboundedly.
|
||||
* - `wsToWebSocketLike()` — wraps a real `ws` WebSocket to the same interface for `main.ts`.
|
||||
* Bytes are COPIED on send (the mux slices/retains buffers) so no caller can alias another's memory.
|
||||
*/
|
||||
import type { WebSocket as WsWebSocket } from 'ws'
|
||||
import type { WebSocketLike } from 'term-relay/data-plane/ws-like.js'
|
||||
|
||||
interface Endpoint extends WebSocketLike {
|
||||
/** Internal: deliver bytes that the peer sent to THIS endpoint. */
|
||||
_deliver(data: Uint8Array): void
|
||||
_closed(): void
|
||||
}
|
||||
|
||||
function makeEndpoint(): Endpoint {
|
||||
let onMessage: ((data: Uint8Array) => void) | null = null
|
||||
let onClose: (() => void) | null = null
|
||||
let peer: Endpoint | null = null
|
||||
const inbox: Uint8Array[] = []
|
||||
let draining = false
|
||||
let closed = false
|
||||
|
||||
const ep: Endpoint & { _link(p: Endpoint): void } = {
|
||||
_link(p: Endpoint) {
|
||||
peer = p
|
||||
},
|
||||
send(data: Uint8Array) {
|
||||
if (closed || peer === null) return
|
||||
peer._deliver(new Uint8Array(data)) // copy: never alias the sender's buffer
|
||||
},
|
||||
close(_code?: number) {
|
||||
if (closed) return
|
||||
closed = true
|
||||
onClose?.()
|
||||
peer?._closed()
|
||||
},
|
||||
onMessage(handler) {
|
||||
onMessage = handler
|
||||
},
|
||||
onClose(handler) {
|
||||
onClose = handler
|
||||
},
|
||||
_deliver(data: Uint8Array) {
|
||||
inbox.push(data)
|
||||
if (draining) return
|
||||
draining = true
|
||||
try {
|
||||
while (inbox.length > 0) {
|
||||
const next = inbox.shift()!
|
||||
onMessage?.(next)
|
||||
}
|
||||
} finally {
|
||||
draining = false
|
||||
}
|
||||
},
|
||||
_closed() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
onClose?.()
|
||||
},
|
||||
}
|
||||
return ep as Endpoint & { _link(p: Endpoint): void }
|
||||
}
|
||||
|
||||
/** An in-process duplex `WebSocketLike` pair: `a.send` → `b`'s message handler and vice versa. */
|
||||
export function makeSocketPair(): { a: WebSocketLike; b: WebSocketLike } {
|
||||
const a = makeEndpoint() as Endpoint & { _link(p: Endpoint): void }
|
||||
const b = makeEndpoint() as Endpoint & { _link(p: Endpoint): void }
|
||||
a._link(b)
|
||||
b._link(a)
|
||||
return { a, b }
|
||||
}
|
||||
|
||||
function toUint8(data: unknown): Uint8Array | null {
|
||||
if (data instanceof Uint8Array) return data
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||
if (Array.isArray(data)) return Buffer.concat(data.map((d) => (d instanceof Uint8Array ? d : Buffer.from(d))))
|
||||
if (Buffer.isBuffer(data)) return new Uint8Array(data)
|
||||
return null
|
||||
}
|
||||
|
||||
/** Adapt a live `ws` WebSocket to `WebSocketLike` (binary frames only). */
|
||||
export function wsToWebSocketLike(ws: WsWebSocket): WebSocketLike {
|
||||
return {
|
||||
send(data: Uint8Array) {
|
||||
ws.send(data, { binary: true })
|
||||
},
|
||||
close(code?: number) {
|
||||
ws.close(code)
|
||||
},
|
||||
onMessage(handler) {
|
||||
ws.on('message', (data: unknown) => {
|
||||
const bytes = toUint8(data)
|
||||
if (bytes !== null) handler(bytes)
|
||||
})
|
||||
},
|
||||
onClose(handler) {
|
||||
ws.on('close', () => handler())
|
||||
},
|
||||
}
|
||||
}
|
||||
BIN
relay-run/tests/integration.test.ts
Normal file
BIN
relay-run/tests/integration.test.ts
Normal file
Binary file not shown.
29
relay-run/tests/smoke.test.ts
Normal file
29
relay-run/tests/smoke.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
// Cross-package resolution smoke test: prove relay-run can bare-import the REAL exports of the
|
||||
// relay packages it wires — including the term-relay data-plane DEEP imports (term-relay has no
|
||||
// barrel index / exports map, so subpath `.js` specifiers resolve to the `.ts` sources).
|
||||
import { onUpgrade, onReattach, issueCapabilityToken, configureVerifyKey } from 'relay-auth'
|
||||
import { createClientHandshake, createHostHandshake, createE2ESession } from 'relay-e2e'
|
||||
import { APP_SUBPROTOCOL, encodeTokenSubprotocol } from 'relay-contracts'
|
||||
import { createRelayNode } from 'term-relay/data-plane/relay-node.js'
|
||||
import { createAgentListener } from 'term-relay/data-plane/agent-listener.js'
|
||||
import { authorizeUpgrade } from 'term-relay/data-plane/upgrade.js'
|
||||
import { createMuxSession } from 'term-relay/mux/mux-session.js'
|
||||
|
||||
describe('relay-run cross-package import smoke', () => {
|
||||
it('resolves relay-auth / relay-e2e / relay-contracts + term-relay data-plane deep imports', () => {
|
||||
expect(typeof onUpgrade).toBe('function')
|
||||
expect(typeof onReattach).toBe('function')
|
||||
expect(typeof issueCapabilityToken).toBe('function')
|
||||
expect(typeof configureVerifyKey).toBe('function')
|
||||
expect(typeof createClientHandshake).toBe('function')
|
||||
expect(typeof createHostHandshake).toBe('function')
|
||||
expect(typeof createE2ESession).toBe('function')
|
||||
expect(APP_SUBPROTOCOL).toBe('term.relay.v1')
|
||||
expect(typeof encodeTokenSubprotocol).toBe('function')
|
||||
expect(typeof createRelayNode).toBe('function')
|
||||
expect(typeof createAgentListener).toBe('function')
|
||||
expect(typeof authorizeUpgrade).toBe('function')
|
||||
expect(typeof createMuxSession).toBe('function')
|
||||
})
|
||||
})
|
||||
15
relay-run/tsconfig.json
Normal file
15
relay-run/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
8
relay-run/vitest.config.ts
Normal file
8
relay-run/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user