/** * buildRelayWorld() — composes the REAL relay-auth (P5) + relay-e2e (P4) + agent (P2 replay) * exports through the in-memory seams in ./fakes and exposes a clean flow API plus the untrusted * "RelaySpy" attacker vantage. Only the true I/O boundaries are faked; every security check is the * production code path. Pass an explicit `now` everywhere (no Date.now in assertions). * * The host identity used for the §4.4 handshake transcript signature is a real WebCrypto Ed25519 * key (relay-auth's own crypto, deep-imported) so the harness needs no @noble import of its own and * no relay-e2e deep import (relay-e2e's `exports` map blocks subpaths). */ import { randomUUID, randomBytes } from 'node:crypto' import type { AeadAlg, E2EEnvelope, E2ESession, HostHello, HostRecord } from 'relay-contracts' import { createClientHandshake, createHostHandshake, createE2ESession, MemoryDevicePinStore, deriveContentKey, sealReplayFrame, openReplayCiphertext, encodeEnvelope, } from 'relay-e2e' import { onUpgrade, onReattach, revoke, revokeToken, signDeviceAuthProof, verifyDeviceProof, type AuthzOutcome, type EnforceDeps, type UpgradeContext, type StepUpPolicy, type RevocationScope, } from 'relay-auth' import { generateEd25519KeyPair, exportEd25519PublicRaw, importEd25519PublicRaw, signEd25519, verifyEd25519, } from 'relay-auth/src/crypto/ed25519.js' import { createReplaySealer, type ReplaySealer } from 'agent' import { fakeAuditSink, fakeHostRegistry, fakeRevocationBus, fakeRevocationStore, fakeSessionRegistry, fakeTokenBucket, makeHostRecord, principal, type AuditFake, type MutableSessionRegistry, type RevocationBusFake, type RevocationFake, type TokenBucketFake, } from './fakes.js' import { craftMalformedDpopBundle, issueCapBundle, resetDpopCacheForTest, setupP5SigningKey, type CapBundle, } from './dpop.js' import { RelaySpy } from './spy.js' export const DEFAULT_NOW = 1_700_000_000 const REPLAY_ALG: AeadAlg = 'xchacha20-poly1305' export const NO_STEP_UP: StepUpPolicy = { required: false, maxAgeSeconds: Number.MAX_SAFE_INTEGER, requiredMethod: 'passkey', } export const STRICT_PASSKEY: StepUpPolicy = { required: true, maxAgeSeconds: 300, requiredMethod: 'passkey', } /** A seeded host: authz identity (hostId/accountId/aud) + §4.4 e2e identity + replay secret. */ export interface HostFixture { readonly label: string readonly hostId: string readonly accountId: string readonly aud: string readonly origin: string readonly agentPubkey: Uint8Array readonly hostContentSecret: Uint8Array readonly replayAlg: AeadAlg /** WebCrypto private key backing the handshake transcript signer. */ readonly signingPriv: CryptoKey } async function makeHostFixture(label: string, accountId: string): Promise { const kp = await generateEd25519KeyPair() const agentPubkey = await exportEd25519PublicRaw(kp.publicKey) const sub = `${label}.term.example.com` return { label, hostId: randomUUID(), accountId, aud: sub, origin: `https://${sub}`, agentPubkey, hostContentSecret: new Uint8Array(randomBytes(32)), replayAlg: REPLAY_ALG, signingPriv: kp.privateKey, } } export interface UpgradeArgs { readonly raw: string readonly dpop: UpgradeContext['dpop'] readonly host: string readonly aud: string readonly origin: string readonly now: number readonly principal?: UpgradeContext['principal'] readonly requiredRight?: UpgradeContext['requiredRight'] readonly activeSessionCount?: number readonly remoteAddrHash?: string } export interface ReattachArgs extends UpgradeArgs { readonly sessionId: string } export interface HandshakeOpts { readonly host?: HostFixture readonly now?: number /** MITM: pubkey the client verifies host_hello against (default = the real agentPubkey). */ readonly verifyAgainstPubkey?: Uint8Array /** MITM: mutate the host_hello the client receives (tampered transcript / hostEphPub). */ readonly tamperHostHello?: (h: HostHello) => HostHello } export interface EstablishedSessions { readonly client: E2ESession readonly host: E2ESession readonly spy: RelaySpy readonly agentPubkey: Uint8Array } export interface RelayWorld { readonly now: number readonly signingKey: CryptoKey readonly publicRaw: Uint8Array readonly hostA: HostFixture readonly hostB: HostFixture readonly allowedOrigins: readonly string[] readonly deps: EnforceDeps readonly audit: AuditFake readonly revocation: RevocationFake readonly buckets: TokenBucketFake readonly bus: RevocationBusFake readonly sessions: MutableSessionRegistry principal(accountId: string, over?: Parameters[1]): ReturnType setStepUpPolicy(policy: StepUpPolicy): void issueCap(o: { accountId: string host: string aud: string rights?: readonly UpgradeContext['requiredRight'][] ttl?: number now?: number }): Promise craftMalformedDpop(o: { accountId: string host: string aud: string now?: number blobLen?: number }): Promise<{ raw: string; dpop: UpgradeContext['dpop'] }> upgrade(a: UpgradeArgs): Promise reattach(a: ReattachArgs): Promise addSession(e: { sessionId: string; hostId: string; accountId: string }): void establishSession(opts?: HandshakeOpts): Promise newReplaySealer(sessionId: string, host?: HostFixture): ReplaySealer /** Browser-side re-derivation + open of a replay frame (throws on AEAD failure / wrong epoch). */ openReplay(sessionId: string, epoch: string, env: E2EEnvelope, host?: HostFixture): Uint8Array revokeToken(jti: string, exp?: number): Promise revoke(scope: RevocationScope): Promise } export async function buildRelayWorld(now: number = DEFAULT_NOW): Promise { const { signingKey, publicRaw } = await setupP5SigningKey() resetDpopCacheForTest() const hostA = await makeHostFixture('alice', 'acct-A') const hostB = await makeHostFixture('bob', 'acct-B') const hostRecords: HostRecord[] = [ makeHostRecord(hostA.accountId, hostA.hostId), makeHostRecord(hostB.accountId, hostB.hostId), ] const hosts = fakeHostRegistry(hostRecords) const sessions = fakeSessionRegistry([]) const revocation = fakeRevocationStore() const buckets = fakeTokenBucket() const audit = fakeAuditSink() const bus = fakeRevocationBus() const allowedOrigins = [hostA.origin, hostB.origin] let stepUpPolicy: StepUpPolicy = NO_STEP_UP const deps: EnforceDeps = { hosts, sessions, revocation, buckets, audit, stepUpPolicyFor: () => stepUpPolicy, } function ctxFor(a: UpgradeArgs): UpgradeContext { return { capabilityRaw: a.raw, originHeader: a.origin, expectedAud: a.aud, requestedHostId: a.host, requiredRight: a.requiredRight ?? 'attach', remoteAddrHash: a.remoteAddrHash ?? 'ip-hash', activeSessionCount: a.activeSessionCount ?? 0, dpop: a.dpop, principal: a.principal ?? null, } } const replayCrypto = { deriveContentKey, sealReplayFrame } return { now, signingKey, publicRaw, hostA, hostB, allowedOrigins, deps, audit, revocation, buckets, bus, sessions, principal, setStepUpPolicy(policy) { stepUpPolicy = policy }, issueCap(o) { return issueCapBundle(signingKey, { accountId: o.accountId, host: o.host, aud: o.aud, rights: o.rights, ttl: o.ttl, now: o.now ?? now, }) }, craftMalformedDpop(o) { return craftMalformedDpopBundle(signingKey, { accountId: o.accountId, host: o.host, aud: o.aud, now: o.now ?? now, blobLen: o.blobLen, }) }, upgrade(a) { return onUpgrade(ctxFor(a), deps, allowedOrigins, a.now) }, reattach(a) { return onReattach({ ...ctxFor(a), sessionId: a.sessionId }, deps, allowedOrigins, a.now) }, addSession(e) { sessions.add(e) }, async establishSession(opts: HandshakeOpts = {}): Promise { const h = opts.host ?? hostA const hsNow = opts.now ?? now const client = createClientHandshake({ aeadOffer: ['xchacha20-poly1305', 'aes-256-gcm'], deviceAuthProofProvider: { proofFor: (_hostId, binding) => signDeviceAuthProof(principal(h.accountId), binding, signingKey, hsNow), }, verifier: { verify: async (pub, transcript, sig) => verifyEd25519(await importEd25519PublicRaw(pub), sig, transcript), }, pinStore: new MemoryDevicePinStore(), hostId: h.hostId, }) const host = createHostHandshake({ signer: { sign: (transcript) => signEd25519(h.signingPriv, transcript) }, agentPubkey: h.agentPubkey, supported: ['xchacha20-poly1305', 'aes-256-gcm'], verifyDeviceProof: (proof, binding) => verifyDeviceProof(proof, binding, hsNow), }) const clientHello = await client.start() const rawHostHello = await host.onClientHello(clientHello) const hostHello = opts.tamperHostHello ? opts.tamperHostHello(rawHostHello) : rawHostHello const clientResult = await client.onHostHello( hostHello, opts.verifyAgainstPubkey ?? h.agentPubkey, ) return { client: createE2ESession('client', clientResult), host: createE2ESession('host', host.result!), spy: new RelaySpy(), agentPubkey: h.agentPubkey, } }, newReplaySealer(sessionId, host = hostA) { return createReplaySealer(host.hostContentSecret, sessionId, host.replayAlg, replayCrypto) }, openReplay(sessionId, epoch, env, host = hostA) { const key = deriveContentKey({ hostContentSecret: host.hostContentSecret, sessionId, alg: host.replayAlg, epoch, }) return openReplayCiphertext(key, encodeEnvelope(env)) }, async revokeToken(jti, exp = now + 60) { await revokeToken(jti, exp, revocation) }, async revoke(scope) { await revoke(scope, revocation, hosts, bus, now) }, } }