Files
web-terminal/e2e/tests/flow.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

100 lines
3.7 KiB
TypeScript

/**
* Happy-path full flow, end-to-end across P5 (auth) + P4 (E2E crypto) + P2 (replay), asserting each
* stage. Everything runs through the REAL package exports; only registries/buckets/sockets are faked.
*/
import { describe, it, expect, beforeEach } from 'vitest'
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
describe('relay flow (happy path)', () => {
let world: RelayWorld
beforeEach(async () => {
world = await buildRelayWorld(NOW)
})
it('issueCap → upgrade allows (origin ok, DPoP bound, deny-by-default satisfied)', async () => {
const { hostA } = world
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
const out = await world.upgrade({
raw: cap.raw,
dpop: cap.dpop,
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
now: NOW,
})
expect(out.ok).toBe(true)
expect(world.audit.events.at(-1)).toMatchObject({ outcome: 'allow', action: 'attach' })
})
it('handshake establishes MATCHING session keys on both sides (client↔host round-trip)', async () => {
const { client, host } = await world.establishSession()
// Matching keys ⇒ bidirectional plaintext round-trips through the direction split.
const c2h = host.open(client.seal(utf8('ls -la')))
expect(fromUtf8(c2h)).toBe('ls -la')
const h2c = client.open(host.seal(utf8('total 0')))
expect(fromUtf8(h2c)).toBe('total 0')
})
it('seals client→host and host→client through the RelaySpy; the RelaySpy never sees plaintext (INV2)', async () => {
const { client, host, spy } = await world.establishSession()
const marker = `PLAINTEXT_${crypto.randomUUID()}`
const up = spy.forward(client.seal(utf8(marker)))
expect(fromUtf8(host.open(up))).toBe(marker)
const down = spy.forward(host.seal(utf8(`reply_${marker}`)))
expect(fromUtf8(client.open(down))).toBe(`reply_${marker}`)
expect(spy.contains(marker)).toBe(false)
expect(spy.captured.length).toBe(2)
})
it('reattach to an own-account session is allowed', async () => {
const { hostA } = world
const sessionId = crypto.randomUUID()
world.addSession({ sessionId, hostId: hostA.hostId, accountId: hostA.accountId })
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
const out = await world.reattach({
raw: cap.raw,
dpop: cap.dpop,
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
sessionId,
now: NOW,
})
expect(out.ok).toBe(true)
expect(world.audit.events.at(-1)).toMatchObject({ outcome: 'allow', action: 'reattach' })
})
it('revokeToken then re-presenting the same jti (fresh DPoP) is denied', async () => {
const { hostA } = world
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
const first = await world.upgrade({
raw: cap.raw,
dpop: cap.dpop,
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
now: NOW,
})
expect(first.ok).toBe(true)
if (!first.ok) return
await world.revokeToken(first.jti)
const second = await world.upgrade({
raw: cap.raw,
dpop: await cap.newDpop(NOW), // fresh DPoP jti so we reach the revocation gate, not the DPoP cache
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
now: NOW,
})
expect(second).toMatchObject({ ok: false, status: 403, reason: 'token_revoked' })
})
})