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'): ReplayKeyParams => ({ hostContentSecret: secret, sessionId, alg, }) 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('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() }) })