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:
Yaojia Wang
2026-07-02 16:41:19 +02:00
parent 3020184054
commit ad5cf06207
11 changed files with 1135 additions and 0 deletions

141
e2e/harness/dpop.ts Normal file
View File

@@ -0,0 +1,141 @@
/**
* P5 signing-key setup + capability-token / DPoP bundle builder. These wire the REAL
* relay-auth issue + DPoP primitives. `resetVerifyKeyForTest`, `resetDpopCacheForTest`,
* `buildDpopProof`, `jwkThumbprint`, and the WebCrypto Ed25519 helpers are TEST-ONLY / internal,
* so they are deep-imported from `relay-auth/src/...` (relay-auth has no `exports` map, so subpath
* imports resolve through the e2e node_modules symlink).
*/
import { randomUUID } from 'node:crypto'
import { issueCapabilityToken, type DpopContext } from 'relay-auth'
import type { CapabilityRight } from 'relay-contracts'
import { encodeBase64UrlBytes } 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 './fakes.js'
export { resetDpopCacheForTest, jwkThumbprint }
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 }
}
export interface Ephemeral {
readonly privateKey: CryptoKey
readonly publicRaw: Uint8Array
}
export 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
readonly htu?: string
readonly htm?: string
}
/**
* 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 (single-use jti / revocation tests) 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 = o.htu ?? `https://${o.aud}/ws`
const htm = o.htm ?? 'GET'
const newDpop = async (at: number = o.now): Promise<DpopContext> => ({
proofJws: await buildDpopProof(eph.privateKey, eph.publicRaw, {
htu,
htm,
jti: randomUUID(),
iat: at,
}),
htu,
htm,
})
return { raw, dpop: await newDpop(o.now), newDpop }
}
/**
* F4 crafting: a capability whose `cnf.jkt` is the thumbprint of a NON-32-byte blob, plus a DPoP
* proof whose `header.jwk.x` decodes to that same blob. The thumbprint check therefore PASSES and
* execution reaches `importEd25519PublicRaw(blob)`, which throws on the bad length — exercising the
* fail-safe (must resolve to false, never reject).
*/
export interface MalformedOpts {
readonly accountId: string
readonly host: string
readonly aud: string
readonly now: number
readonly blobLen?: number
}
export async function craftMalformedDpopBundle(
signingKey: CryptoKey,
o: MalformedOpts,
): Promise<{ raw: string; dpop: DpopContext }> {
const blob = new Uint8Array(o.blobLen ?? 16).fill(7)
const cnfJkt = await jwkThumbprint(blob) // 43-char base64url regardless of blob length
const raw = await issueCapabilityToken(
{
principal: principal(o.accountId),
aud: o.aud,
host: o.host,
rights: ['attach'],
ttlSeconds: 45,
cnfJkt,
},
signingKey,
o.now,
)
const htu = `https://${o.aud}/ws`
const enc = (x: unknown): string =>
encodeBase64UrlBytes(new TextEncoder().encode(JSON.stringify(x)))
const h = enc({ typ: 'dpop+ed25519', jwk: { crv: 'Ed25519', kty: 'OKP', x: encodeBase64UrlBytes(blob) } })
const p = enc({ htu, htm: 'GET', jti: randomUUID(), iat: o.now })
const s = encodeBase64UrlBytes(new Uint8Array(64)) // arbitrary signature bytes
return { raw, dpop: { proofJws: `${h}.${p}.${s}`, htu, htm: 'GET' } }
}

132
e2e/harness/fakes.ts Normal file
View File

@@ -0,0 +1,132 @@
/**
* In-memory port fakes — 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. Adapted from `relay-auth/test/_helpers.ts` (same shapes, bare import paths).
*/
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,
status: HostRecord['status'] = 'online',
): HostRecord {
return {
hostId,
accountId,
subdomain: 'sub-' + hostId,
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) }
}

34
e2e/harness/spy.ts Normal file
View File

@@ -0,0 +1,34 @@
/**
* RelaySpy — the untrusted-relay / attacker vantage. A passthrough that records every ciphertext
* byte it forwards (exactly what a malicious relay can see). INV2: a known plaintext marker must
* NEVER appear in `captured`. Mirrors the `relay-e2e/test/integration.test.ts` spy.
*/
import { MAX_FRAME_BYTES } from 'relay-e2e'
export class RelaySpy {
readonly captured: Uint8Array[] = []
/** Forward a sealed payload, recording a copy of the ciphertext the relay observes. */
forward(payload: Uint8Array): Uint8Array {
if (payload.length > MAX_FRAME_BYTES) {
throw new Error(`payload exceeds MAX_FRAME_BYTES (${payload.length} > ${MAX_FRAME_BYTES})`)
}
this.captured.push(payload.slice())
return payload
}
/** Latin1 concatenation of everything the relay saw — for substring canary scans. */
snapshot(): string {
return this.captured.map((b) => Buffer.from(b).toString('latin1')).join(' ')
}
/** True if `marker` appears in ANY captured frame (utf8 or latin1) — INV2 tripwire. */
contains(marker: string): boolean {
if (this.snapshot().includes(marker)) return true
return this.captured.some(
(b) =>
Buffer.from(b).toString('utf8').includes(marker) ||
Buffer.from(b).toString('latin1').includes(marker),
)
}
}

332
e2e/harness/world.ts Normal file
View 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)
},
}
}