Files
web-terminal/e2e/tests/adversarial-transport.test.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

116 lines
5.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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)
})
})
})