Files
web-terminal/e2e/harness/world.ts
Yaojia Wang ad5cf06207 test(relay): cross-package e2e adversarial security harness
New e2e/ package wires the REAL P5(auth)+P4(crypto)+P2(agent) exports through
in-memory seams and an untrusted-relay attacker vantage (RelaySpy). Dynamically
re-validates F1–F4/F6 plus MITM/reflection/replay/reorder/INV2/cross-tenant/
single-use — every assertion runs the production code path. 21 tests green, tsc
clean. node_modules via symlinks (gitignored); no npm install needed.
2026-07-02 16:41:19 +02:00

333 lines
10 KiB
TypeScript

/**
* 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<HostFixture> {
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<typeof principal>[1]): ReturnType<typeof principal>
setStepUpPolicy(policy: StepUpPolicy): void
issueCap(o: {
accountId: string
host: string
aud: string
rights?: readonly UpgradeContext['requiredRight'][]
ttl?: number
now?: number
}): Promise<CapBundle>
craftMalformedDpop(o: {
accountId: string
host: string
aud: string
now?: number
blobLen?: number
}): Promise<{ raw: string; dpop: UpgradeContext['dpop'] }>
upgrade(a: UpgradeArgs): Promise<AuthzOutcome>
reattach(a: ReattachArgs): Promise<AuthzOutcome>
addSession(e: { sessionId: string; hostId: string; accountId: string }): void
establishSession(opts?: HandshakeOpts): Promise<EstablishedSessions>
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<void>
revoke(scope: RevocationScope): Promise<void>
}
export async function buildRelayWorld(now: number = DEFAULT_NOW): Promise<RelayWorld> {
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<EstablishedSessions> {
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)
},
}
}