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:
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,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user