import { beforeEach, describe, expect, it, vi } from 'vitest' import { importAeadKey, createE2ESession } from 'relay-e2e' import { randomBytes } from 'relay-e2e' import type { AeadKey, E2ESession, HandshakeResult } from 'relay-contracts' import { readConfig } from '../src/config' import { createHostPinStore } from '../src/host-pin-store' import { createE2ETransport, type E2EHandshakeIo, type E2ETransportDeps, type FprDecision, type RunHandshakeArgs, } from '../src/e2e-socket' import type { WebSocketLike } from '../src/ws-transport' import { APP_SUBPROTOCOL } from 'relay-contracts' const cfg = readConfig({ protocol: 'https:', host: 'alice.term.example.com', hostname: 'alice.term.example.com', }) /** A real relay-e2e loopback: client + host sessions sharing DirectionalKeys (genuine crypto). */ function loopbackPair(): { client: E2ESession; host: E2ESession } { const c2h: AeadKey = importAeadKey(randomBytes(32), 'aes-256-gcm', 'c2h') const h2c: AeadKey = importAeadKey(randomBytes(32), 'aes-256-gcm', 'h2c') const result: HandshakeResult = { keys: { c2h, h2c }, aead: 'aes-256-gcm', transcript: new Uint8Array() } return { client: createE2ESession('client', result), host: createE2ESession('host', result) } } class MockWs implements WebSocketLike { static last: MockWs | null = null binaryType = '' protocol = '' readonly sent: Array = [] closed = false onopen: ((ev: unknown) => void) | null = null onmessage: ((ev: { data: unknown }) => void) | null = null onclose: ((ev: { code?: number; reason?: string }) => void) | null = null onerror: ((ev: unknown) => void) | null = null constructor( readonly url: string, readonly protocols?: string | readonly string[], ) { MockWs.last = this } send(data: ArrayBufferView | ArrayBufferLike | string): void { this.sent.push(data) } close(): void { this.closed = true this.onclose?.({ code: 1000 }) } fireOpen(): void { this.protocol = APP_SUBPROTOCOL this.onopen?.({}) } fireMessage(data: unknown): void { this.onmessage?.({ data }) } } const ctx = { deviceAuthProofProvider: { proofFor: async () => 'proof' }, pinStore: { get: async () => null, pin: async () => {} }, hostContentSecret: new Uint8Array(32), } /** * Build a transport whose injected `runHandshake` honours the fingerprint gate (calling * verifyOfferedFpr with `offeredFpr`) and, on 'trust', returns the real relay-e2e client session. */ function makeTransport(opts: { expectedFpr: string offeredFpr: string onPinMismatch?: (e: string, o: string, s: 'api' | 'cache') => Promise pins?: ReturnType }) { const { client, host } = loopbackPair() const pins = opts.pins ?? createHostPinStore(localStorage) const runHandshake = vi.fn( async (_io: E2EHandshakeIo, args: RunHandshakeArgs): Promise => { const decision = await args.verifyOfferedFpr(opts.offeredFpr) if (decision === 'abort') throw new Error('handshake aborted: fingerprint mismatch') return client }, ) const deps: E2ETransportDeps = { runHandshake, context: ctx, agentPubkey: new Uint8Array([1, 2, 3]), } const onPinMismatch = opts.onPinMismatch ?? (async () => 'abort' as FprDecision) const transport = createE2ETransport( cfg, 'RAWTOK', 'h1', opts.expectedFpr, pins, onPinMismatch, deps, ) return { transport, host, pins, runHandshake } } async function openTransport(t: ReturnType['transport']): Promise { const opened = t.open() MockWs.last!.fireOpen() await opened } beforeEach(() => { localStorage.clear() MockWs.last = null }) // Patch the ctor into deps via globalThis for these tests. function withMockWs(fn: () => T): T { const original = (globalThis as { WebSocket?: unknown }).WebSocket ;(globalThis as { WebSocket?: unknown }).WebSocket = MockWs as unknown as typeof WebSocket try { return fn() } finally { ;(globalThis as { WebSocket?: unknown }).WebSocket = original } } describe('createE2ETransport (T8) — INV2 / INV13 / TOFU trust root', () => { it('first-connect MITM: relay fpr ≠ API expectedFpr → abort, no session, nothing recorded', async () => { await withMockWs(async () => { const onPinMismatch = vi.fn(async () => 'abort' as FprDecision) const { transport, pins } = makeTransport({ expectedFpr: 'fpr-API', offeredFpr: 'fpr-RELAY-FORGED', onPinMismatch, }) await expect(openTransport(transport)).rejects.toThrow() expect(onPinMismatch).toHaveBeenCalledWith('fpr-API', 'fpr-RELAY-FORGED', 'api') expect(pins.get('h1')).toBeNull() // never recorded a relay-forwarded value expect(MockWs.last!.closed).toBe(true) }) }) it('happy path: fpr == expectedFpr → session established, API value cached (audit trail)', async () => { await withMockWs(async () => { const { transport, pins } = makeTransport({ expectedFpr: 'fpr-API', offeredFpr: 'fpr-API' }) await openTransport(transport) expect(pins.get('h1')).toBe('fpr-API') // the API-sourced value, not a handshake-derived one }) }) it('INV2: a known plaintext marker never appears un-AEAD\'d on the wire', async () => { await withMockWs(async () => { const { transport } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' }) await openTransport(transport) const marker = new TextEncoder().encode('SECRET-MARKER-12345') transport.send(marker) const framed = MockWs.last!.sent.at(-1) as Uint8Array expect(new TextDecoder().decode(framed)).not.toContain('SECRET-MARKER-12345') }) }) it('round-trips: a host-sealed frame decrypts to the original plaintext via onMessage', async () => { await withMockWs(async () => { const { transport, host } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' }) const received: Uint8Array[] = [] transport.onMessage((b) => received.push(b)) await openTransport(transport) MockWs.last!.fireMessage(host.seal(new TextEncoder().encode('hello'))) expect(new TextDecoder().decode(received[0]!)).toBe('hello') }) }) it('INV13 tamper: flipping a ciphertext byte → AEAD tag fails → socket torn down', async () => { await withMockWs(async () => { const { transport, host } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' }) let closeReason = '' transport.onClose((r) => (closeReason = r)) await openTransport(transport) const frame = Uint8Array.from(host.seal(new TextEncoder().encode('data'))) const last = frame.length - 1 frame[last] = (frame[last] ?? 0) ^ 0xff // flip a tag/ciphertext byte MockWs.last!.fireMessage(frame) expect(closeReason).toBe('e2e-verify-failed') expect(MockWs.last!.closed).toBe(true) }) }) it('INV13 replay: re-injecting a captured frame → rejected → tear-down', async () => { await withMockWs(async () => { const { transport, host } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' }) let closeReason = '' transport.onClose((r) => (closeReason = r)) await openTransport(transport) const frame = host.seal(new TextEncoder().encode('data')) MockWs.last!.fireMessage(frame) // seq 0 accepted MockWs.last!.fireMessage(frame) // replay of seq 0 → strict-successor guard rejects expect(closeReason).toBe('e2e-verify-failed') }) }) it('INV13 reorder: seq=1 before seq=0 → rejected', async () => { await withMockWs(async () => { const { transport, host } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' }) let closeReason = '' transport.onClose((r) => (closeReason = r)) await openTransport(transport) host.seal(new TextEncoder().encode('zero')) // consume seq 0 on the host sender const frameOne = host.seal(new TextEncoder().encode('one')) // seq 1 MockWs.last!.fireMessage(frameOne) // client expects seq 0 first expect(closeReason).toBe('e2e-verify-failed') }) }) it('INV13 reflection: a client→host frame re-injected as host→client → rejected (direction-bound)', async () => { await withMockWs(async () => { const { transport } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' }) let closeReason = '' transport.onClose((r) => (closeReason = r)) await openTransport(transport) // Capture a genuine outbound (client→host) frame and reflect it into the inbound path. transport.send(new TextEncoder().encode('outbound')) const reflected = MockWs.last!.sent.at(-1) as Uint8Array MockWs.last!.fireMessage(reflected) expect(closeReason).toBe('e2e-verify-failed') // c2h frame cannot verify under h2c }) }) it('key non-exposure: no key bytes reach localStorage; only the public fpr is cached', async () => { await withMockWs(async () => { const { transport } = makeTransport({ expectedFpr: 'fpr-API', offeredFpr: 'fpr-API' }) await openTransport(transport) transport.send(new TextEncoder().encode('x')) const dump = JSON.stringify(localStorage) expect(dump).toContain('fpr-API') expect(dump).not.toMatch(/__aeadKey|CryptoKey/) }) }) it('a socket error during the E2E open rejects (no partial session)', async () => { await withMockWs(async () => { const { transport } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' }) const opened = transport.open() MockWs.last!.onerror?.({}) await expect(opened).rejects.toThrow('websocket error') }) }) it('a plain server close during DATA phase surfaces its reason string', async () => { await withMockWs(async () => { const { transport } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' }) let reason = '' transport.onClose((r) => (reason = r)) await openTransport(transport) MockWs.last!.onclose?.({ code: 1000, reason: 'agent shutdown' }) expect(reason).toBe('agent shutdown') }) }) it('a bare close (no code/reason) surfaces the default "closed" reason', async () => { await withMockWs(async () => { const { transport } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' }) let reason = '' transport.onClose((r) => (reason = r)) await openTransport(transport) MockWs.last!.onclose?.({}) expect(reason).toBe('closed') }) }) it('operator override: onPinMismatch("api") → "trust" lets the handshake proceed (branch)', async () => { await withMockWs(async () => { const onPinMismatch = vi.fn(async () => 'trust' as FprDecision) const { transport, pins } = makeTransport({ expectedFpr: 'fpr-API', offeredFpr: 'fpr-DIFFERENT', onPinMismatch, }) await openTransport(transport) expect(onPinMismatch).toHaveBeenCalledWith('fpr-API', 'fpr-DIFFERENT', 'api') expect(pins.get('h1')).toBe('fpr-API') // still records the authoritative API value }) }) it('send() before the session is established throws (never sends plaintext)', () => { withMockWs(() => { const { transport } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' }) expect(() => transport.send(new Uint8Array([1]))).toThrow('e2e transport not open') }) }) it('a non-abort handshake failure tears the socket down and rejects open()', async () => { await withMockWs(async () => { const pins = createHostPinStore(localStorage) const deps: E2ETransportDeps = { runHandshake: vi.fn(async () => { throw new Error('agent unreachable') }), context: ctx, agentPubkey: new Uint8Array([1]), } const transport = createE2ETransport(cfg, 'RAWTOK', 'h1', 'f', pins, async () => 'trust', deps) let closeReason = '' transport.onClose((r) => (closeReason = r)) const opened = transport.open() MockWs.last!.fireOpen() await expect(opened).rejects.toThrow('agent unreachable') expect(closeReason).toBe('e2e-handshake-failed') expect(pins.get('h1')).toBeNull() }) }) it('a 4403 server close during the DATA phase surfaces onClose("forbidden")', async () => { await withMockWs(async () => { const { transport } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' }) let closeReason = '' transport.onClose((r) => (closeReason = r)) await openTransport(transport) MockWs.last!.onclose?.({ code: 4403 }) expect(closeReason).toBe('forbidden') }) }) it('cache drift: a later expectedFpr differing from the cache surfaces onPinMismatch(...,"cache")', async () => { await withMockWs(async () => { const pins = createHostPinStore(localStorage) pins.record('h1', 'fpr-OLD') const onPinMismatch = vi.fn(async () => 'trust' as FprDecision) const { transport } = makeTransport({ expectedFpr: 'fpr-NEW', offeredFpr: 'fpr-NEW', onPinMismatch, pins, }) await openTransport(transport) expect(onPinMismatch).toHaveBeenCalledWith('fpr-NEW', 'fpr-OLD', 'cache') expect(pins.get('h1')).toBe('fpr-NEW') // API value is authoritative — cache updated }) }) })