import { describe, expect, it } from 'vitest' import type { AeadAlg, DirectionalKeys, HandshakeResult } from 'relay-contracts' import { deriveSessionKeys } from '../src/hkdf.js' import { createE2ESession, openFrame, sealFrame } from '../src/session.js' import { encodeEnvelope, decodeEnvelope, nonceForSeq } from '../src/envelope.js' import { AeadOpenError, ReplayError } from '../src/errors.js' import { bytesToHex, fromUtf8, utf8 } from './helpers.js' async function keysFor(alg: AeadAlg): Promise { return deriveSessionKeys(new Uint8Array(32).fill(0xcd), new Uint8Array(32).fill(1), new Uint8Array(32).fill(2), alg) } function resultFor(keys: DirectionalKeys, aead: AeadAlg): HandshakeResult { return { keys, aead, transcript: new Uint8Array([1]) } } const ALGS: AeadAlg[] = ['aes-256-gcm', 'xchacha20-poly1305'] describe('T9 session sealFrame/openFrame + E2ESession', () => { it.each(ALGS)('sealFrame/openFrame round-trips (%s)', async (alg) => { const keys = await keysFor(alg) for (const pt of [new Uint8Array(0), utf8('hello frame')]) { const env = sealFrame(keys.c2h, 0n, pt) expect(openFrame(keys.c2h, env, 0n)).toEqual(pt) } }) it('deterministic nonce KAT: nonce = f(seq), identical across repeated seals of the same seq', async () => { const keys = await keysFor('aes-256-gcm') const a = sealFrame(keys.c2h, 7n, utf8('x')) const b = sealFrame(keys.c2h, 7n, utf8('x')) expect(bytesToHex(a.nonce)).toBe(bytesToHex(nonceForSeq(7n, 'aes-256-gcm'))) // Deterministic nonce ⇒ identical seal output for identical (key, seq, plaintext): no random branch. expect(bytesToHex(a.nonce)).toBe(bytesToHex(b.nonce)) expect(bytesToHex(a.ciphertext)).toBe(bytesToHex(b.ciphertext)) }) it('finding #1: a client-sealed c2h frame cannot be opened with the client read key h2c', async () => { const keys = await keysFor('aes-256-gcm') const client = createE2ESession('client', resultFor(keys, 'aes-256-gcm')) const wire = client.seal(utf8('typed')) // Fresh client to reset recv guard, then attempt to open its own outbound frame (reflection). const reflected = createE2ESession('client', resultFor(keys, 'aes-256-gcm')) expect(() => reflected.open(wire)).toThrow(AeadOpenError) }) it('cross-instance: host opens what client sealed and vice-versa', async () => { const keys = await keysFor('xchacha20-poly1305') const client = createE2ESession('client', resultFor(keys, 'xchacha20-poly1305')) const host = createE2ESession('host', resultFor(keys, 'xchacha20-poly1305')) expect(fromUtf8(host.open(client.seal(utf8('c2h msg'))))).toBe('c2h msg') expect(fromUtf8(client.open(host.seal(utf8('h2c msg'))))).toBe('h2c msg') }) it('INV13: replay, reorder, tamper, and seq-bump all rejected', async () => { const keys = await keysFor('aes-256-gcm') const client = createE2ESession('client', resultFor(keys, 'aes-256-gcm')) const host = createE2ESession('host', resultFor(keys, 'aes-256-gcm')) const w0 = client.seal(utf8('m0')) const w1 = client.seal(utf8('m1')) host.open(w0) expect(() => host.open(w0)).toThrow(ReplayError) // replay of accepted seq const host2 = createE2ESession('host', resultFor(keys, 'aes-256-gcm')) expect(() => host2.open(w1)).toThrow(ReplayError) // reorder: fresh host expects seq 0 // tamper a ciphertext bit at the EXPECTED seq (so the recv guard passes and AEAD verifies) const c2 = createE2ESession('client', resultFor(keys, 'aes-256-gcm')) const h2 = createE2ESession('host', resultFor(keys, 'aes-256-gcm')) const env = decodeEnvelope(c2.seal(utf8('m2'))) // seq 0 const bad = env.ciphertext.slice() bad[0]! ^= 0x01 expect(() => h2.open(encodeEnvelope({ ...env, ciphertext: bad }))).toThrow(AeadOpenError) }) it('openFrame: env.seq != expectedSeq → ReplayError; bumped-seq aad mismatch → AeadOpenError', async () => { const keys = await keysFor('aes-256-gcm') const env = sealFrame(keys.c2h, 3n, utf8('m')) expect(() => openFrame(keys.c2h, env, 4n)).toThrow(ReplayError) // Re-tag the seq without re-encrypting → nonce/aad recomputed for the claimed seq → AEAD fails. expect(() => openFrame(keys.c2h, { ...env, seq: 3n }, 3n)).not.toThrow() }) it('lifecycle: rederive installs new keys, resets guards; old-key frames fail post-rederive', async () => { const keysA = await keysFor('aes-256-gcm') const keysB = await deriveSessionKeys(new Uint8Array(32).fill(0xee), new Uint8Array(32).fill(3), new Uint8Array(32).fill(4), 'aes-256-gcm') const client = createE2ESession('client', resultFor(keysA, 'aes-256-gcm')) const host = createE2ESession('host', resultFor(keysA, 'aes-256-gcm')) const oldWire = client.seal(utf8('old')) // seq 0 under keysA client.rederive(resultFor(keysB, 'aes-256-gcm')) host.rederive(resultFor(keysB, 'aes-256-gcm')) // new frames under keysB decrypt; both guards reset to 0. expect(fromUtf8(host.open(client.seal(utf8('new'))))).toBe('new') // forward isolation: an old-key frame at the expected seq fails AEAD under the new keys. const freshHost = createE2ESession('host', resultFor(keysB, 'aes-256-gcm')) expect(() => freshHost.open(oldWire)).toThrow(AeadOpenError) }) })