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:
115
e2e/tests/adversarial-transport.test.ts
Normal file
115
e2e/tests/adversarial-transport.test.ts
Normal 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 client’s 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user