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.
100 lines
3.7 KiB
TypeScript
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' })
|
|
})
|
|
})
|