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.
This commit is contained in:
332
e2e/harness/world.ts
Normal file
332
e2e/harness/world.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user