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

View File

@@ -0,0 +1,115 @@
/**
* Transport (P4) attack matrix, run from the untrusted-relay / attacker vantage (the RelaySpy).
* Each case ASSERTS the defense holds. Wires the REAL relay-e2e handshake/session + agent replay
* sealer through the harness. Pass an explicit `now` everywhere.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { FingerprintMismatchError } from 'relay-e2e'
import { buildRelayWorld, DEFAULT_NOW, type RelayWorld } from '../harness/world.js'
const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s)
const fromUtf8 = (b: Uint8Array): string => new TextDecoder().decode(b)
const NOW = DEFAULT_NOW
function flipByte(b: Uint8Array, i = 0): Uint8Array {
const c = b.slice()
c[i] = (c[i]! ^ 0xff) & 0xff
return c
}
describe('adversarial — transport (P4)', () => {
let world: RelayWorld
beforeEach(async () => {
world = await buildRelayWorld(NOW)
})
describe('MITM: host_hello must verify against the registry key', () => {
it('rejects when the client verifies host_hello against a WRONG agentPubkey (no keys derived)', async () => {
// The malicious relay swaps in a key it controls; the client checks against hostB's real key.
await expect(
world.establishSession({ verifyAgainstPubkey: world.hostB.agentPubkey }),
).rejects.toBeInstanceOf(FingerprintMismatchError)
})
it('rejects a tampered host_hello (flipped hostEphPub breaks the transcript signature)', async () => {
await expect(
world.establishSession({
tamperHostHello: (h) => ({ ...h, hostEphPub: flipByte(h.hostEphPub) }),
}),
).rejects.toBeInstanceOf(FingerprintMismatchError)
})
})
it('reflection: a c2h frame fed back into the clients own open is rejected (direction split)', async () => {
const { client } = await world.establishSession()
const c2h = client.seal(utf8('sudo rm -rf /'))
// Reflecting the client's own ciphertext back to it must fail: the client opens with the h2c
// read key + h2c aad label, so the tag over the c2h direction never verifies.
expect(() => client.open(c2h)).toThrow()
})
it('replay: delivering the same valid frame twice → the second open throws (SequenceGuard)', async () => {
const { client, host, spy } = await world.establishSession()
const f0 = spy.forward(client.seal(utf8('whoami')))
expect(fromUtf8(host.open(f0))).toBe('whoami')
expect(() => host.open(f0)).toThrow() // seq 0 already consumed → strict-successor guard
})
it('reorder: delivering seq 1 before seq 0 → open throws (strict successor)', async () => {
const { client, host } = await world.establishSession()
const f0 = client.seal(utf8('cmd-0'))
const f1 = client.seal(utf8('cmd-1'))
expect(() => host.open(f1)).toThrow() // expected seq 0, got 1
// and the in-order pair still works on a fresh pair (sanity)
expect(fromUtf8(host.open(f0))).toBe('cmd-0')
expect(fromUtf8(host.open(f1))).toBe('cmd-1')
})
it('INV2: the RelaySpy ciphertext never contains the plaintext marker', async () => {
const { client, host, spy } = await world.establishSession()
const marker = `TOP_SECRET_${crypto.randomUUID()}`
for (let i = 0; i < 3; i++) {
const wire = spy.forward(client.seal(utf8(`${marker}#${i}`)))
expect(fromUtf8(host.open(wire))).toBe(`${marker}#${i}`)
}
expect(spy.contains(marker)).toBe(false)
expect(spy.captured.length).toBe(3)
})
describe('F6 (dynamic): recoverable K_content must not reuse (key, nonce) across sealer generations', () => {
it('two generations for the same (secret, sessionId) get different epochs → different keys at seq 0', () => {
const sessionId = 'sess-restart-1'
const gen1 = world.newReplaySealer(sessionId)
const gen2 = world.newReplaySealer(sessionId) // simulates an agent restart / re-attach
expect(gen2.epoch).not.toBe(gen1.epoch)
const pt = utf8('IDENTICAL PLAINTEXT AT SEQ 0')
const e1 = gen1.seal(pt)
const e2 = gen2.seal(pt)
// Same deterministic nonce (seq 0) …
expect(e1.seq).toBe(0n)
expect(e2.seq).toBe(0n)
expect(Buffer.from(e1.nonce).equals(Buffer.from(e2.nonce))).toBe(true)
// … yet a FRESH key per generation ⇒ ciphertext + tag differ (no (key, nonce) reuse).
expect(Buffer.from(e1.ciphertext).equals(Buffer.from(e2.ciphertext))).toBe(false)
expect(Buffer.from(e1.tag).equals(Buffer.from(e2.tag))).toBe(false)
// Cross-generation open fails: gen1's epoch-derived key cannot open gen2's frame.
expect(() => world.openReplay(sessionId, gen1.epoch, e2)).toThrow()
// Recoverability WITHIN a generation is preserved (same epoch ⇒ same key).
expect(fromUtf8(world.openReplay(sessionId, gen1.epoch, e1))).toBe('IDENTICAL PLAINTEXT AT SEQ 0')
expect(fromUtf8(world.openReplay(sessionId, gen2.epoch, e2))).toBe('IDENTICAL PLAINTEXT AT SEQ 0')
})
it('the replay ciphertext itself never contains the plaintext marker (INV2 on the replay path)', () => {
const sealer = world.newReplaySealer('sess-inv2')
const marker = 'REPLAY_MARKER_ABC'
const env = sealer.seal(utf8(marker))
expect(Buffer.from(env.ciphertext).toString('latin1')).not.toContain(marker)
expect(Buffer.from(env.ciphertext).toString('utf8')).not.toContain(marker)
})
})
})