import { describe, expect, it } from 'vitest' import type { AeadAlg, ReplayKeyParams } from 'relay-contracts' import { unwrapAeadKey } from '../src/aead-key.js' import { encodeEnvelope } from '../src/envelope.js' import { AeadOpenError } from '../src/errors.js' import { deriveContentKey, openReplayCiphertext, sealReplayFrame } from '../src/replay-key.js' import { bytesToHex, fromUtf8, utf8 } from './helpers.js' const params = ( secret: Uint8Array, sessionId: string, alg: AeadAlg = 'aes-256-gcm', epoch = 'gen-0', ): ReplayKeyParams => ({ hostContentSecret: secret, sessionId, alg, epoch, }) describe('T10 recoverable replay content-key', () => { it('load-bearing: seal under K_content, "reload" (new key object), re-derive, decrypt survives', () => { const secret = new Uint8Array(32).fill(0x11) // Device X seals output; the base-app ring buffer stores the ciphertext bytes. const ringBuffer: Uint8Array[] = [] const kSeal = deriveContentKey(params(secret, 'sess-1')) ringBuffer.push(encodeEnvelope(sealReplayFrame(kSeal, 0n, utf8('scrollback line')))) // Reload: brand new derivation, no in-memory key. const kReload = deriveContentKey(params(secret, 'sess-1')) expect(fromUtf8(openReplayCiphertext(kReload, ringBuffer[0]!))).toBe('scrollback line') }) it('second authorized device (same secret) re-derives the identical K_content → decrypts', () => { const secret = new Uint8Array(32).fill(0x22) const wire = encodeEnvelope(sealReplayFrame(deriveContentKey(params(secret, 's')), 0n, utf8('mirror'))) const deviceY = deriveContentKey(params(secret, 's')) expect(fromUtf8(openReplayCiphertext(deviceY, wire))).toBe('mirror') }) it('K_content is deterministic per (secret, sessionId, alg)', () => { const secret = new Uint8Array(32).fill(0x33) expect(bytesToHex(unwrapAeadKey(deriveContentKey(params(secret, 's'))).raw)).toBe( bytesToHex(unwrapAeadKey(deriveContentKey(params(secret, 's'))).raw), ) }) it('SECURITY: a different / cross-session / cross-host secret cannot open (reinforces INV1)', () => { const secretA = new Uint8Array(32).fill(0x44) const secretB = new Uint8Array(32).fill(0x45) const wire = encodeEnvelope(sealReplayFrame(deriveContentKey(params(secretA, 's1')), 0n, utf8('x'))) // different host secret expect(() => openReplayCiphertext(deriveContentKey(params(secretB, 's1')), wire)).toThrow(AeadOpenError) // different session id (per-sessionId key, no cross-session reuse) expect(() => openReplayCiphertext(deriveContentKey(params(secretA, 's2')), wire)).toThrow(AeadOpenError) }) it('F6: SAME (secret, sessionId) but DIFFERENT epoch ⇒ different raw key bytes (no seq=0 (key,nonce) reuse across generations)', () => { const secret = new Uint8Array(32).fill(0x66) // Two sealer generations (e.g. before/after an agent restart) that both reset seq to 0. const kA = deriveContentKey(params(secret, 'sess-F6', 'aes-256-gcm', 'epoch-A')) const kB = deriveContentKey(params(secret, 'sess-F6', 'aes-256-gcm', 'epoch-B')) // Raw key bytes MUST differ, so sealFrame(kA, 0n) and sealFrame(kB, 0n) never share (key, nonce). expect(bytesToHex(unwrapAeadKey(kA).raw)).not.toBe(bytesToHex(unwrapAeadKey(kB).raw)) // And a frame sealed under one epoch cannot be opened with the other epoch's key. const wireA = encodeEnvelope(sealReplayFrame(kA, 0n, utf8('generation A, seq 0'))) expect(() => openReplayCiphertext(kB, wireA)).toThrow(AeadOpenError) }) it('F6: SAME (secret, sessionId, epoch) ⇒ identical key — recoverability WITHIN a generation preserved', () => { const secret = new Uint8Array(32).fill(0x67) const kSeal = deriveContentKey(params(secret, 'sess-F6', 'aes-256-gcm', 'epoch-E')) const wire = encodeEnvelope(sealReplayFrame(kSeal, 0n, utf8('same-epoch replay'))) // Browser re-derives the key for the SAME epoch E carried with the ring buffer. const kReDerived = deriveContentKey(params(secret, 'sess-F6', 'aes-256-gcm', 'epoch-E')) expect(bytesToHex(unwrapAeadKey(kSeal).raw)).toBe(bytesToHex(unwrapAeadKey(kReDerived).raw)) expect(fromUtf8(openReplayCiphertext(kReDerived, wire))).toBe('same-epoch replay') }) it('revocation (INV12): a revoked wrap yields no secret → no key derivable for that host going forward', () => { // The unwrap mechanism is P5/P2; P4 asserts derivation is GATED on having the unwrapped secret. const revokedUnwrap = (): Uint8Array => { throw new Error('revoked: hostContentSecret wrap no longer unwraps') } expect(() => deriveContentKey(params(revokedUnwrap(), 's1'))).toThrow() }) })