Files
web-terminal/agent/test/replaySeal.test.ts
Yaojia Wang 3020184054 fix(relay): close F6 replay K_content nonce reuse via per-generation epoch-in-key
The recoverable replay key was stable per (hostContentSecret, sessionId) while the
agent-side sealer resets its deterministic-nonce seq to 0 on every restart/re-attach
→ two sealer generations sealed distinct plaintext under the SAME (key, nonce).

Add a required 'epoch' to ReplayKeyParams, fold it into the K_content HKDF salt
(sessionId U+001F epoch), mint a fresh epoch per createReplaySealer generation and
expose it, and thread it through ReplaySource so the browser re-derives the matching
key. Fresh epoch per generation ⇒ fresh key ⇒ seq=0 can never collide; recoverability
within a generation is preserved.

Touches relay-contracts/relay-e2e/agent/relay-web. Green: contracts 81, e2e 78,
agent 133, web 99; tsc clean. Regression proves same seq-0 nonce, different key.
2026-07-02 16:41:19 +02:00

114 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it, vi } from 'vitest'
import type { AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts'
import { createReplaySealer, type ReplayCrypto } from '../src/e2e/replaySeal.js'
/**
* Fake AEAD. The derived key is a tag that DEPENDS on every ReplayKeyParams field — crucially on
* `epoch` (F6), so two sealer generations for the same (secret, sessionId, alg) yield DIFFERENT keys.
* The tag leads with `epoch` so key[0] also varies per generation; ciphertext = plaintext XOR key[0]
* (a UUID's first char is a hex digit 0x300x66 → always nonzero, so the marker is always hidden).
*/
function fakeCrypto(): ReplayCrypto & { derivations: ReplayKeyParams[]; keys: Uint8Array[] } {
const derivations: ReplayKeyParams[] = []
const keys: Uint8Array[] = []
return {
derivations,
keys,
deriveContentKey(params: ReplayKeyParams): AeadKey {
derivations.push(params)
const tag = new TextEncoder().encode(
`${params.epoch}:${Buffer.from(params.hostContentSecret).toString('hex')}:${params.sessionId}:${params.alg}`,
)
keys.push(tag)
return tag as unknown as AeadKey
},
sealReplayFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope {
const kb = (key as unknown as Uint8Array)[0] ?? 0x5a
const ciphertext = plaintext.map((b) => b ^ kb)
return { seq, nonce: new Uint8Array([Number(seq & 0xffn)]), ciphertext, tag: new Uint8Array([0xaa]) }
},
}
}
const SECRET = new Uint8Array([1, 2, 3, 4])
describe('createReplaySealer (T19, FIX 3)', () => {
it('folds the exposed per-generation epoch into K_content, deriving it exactly once', () => {
const c = fakeCrypto()
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
expect(c.derivations).toHaveLength(1)
expect(c.derivations[0]!.epoch).toBe(sealer.epoch)
expect(sealer.epoch).toMatch(/^[0-9a-f-]{36}$/) // randomUUID shape
})
it('recoverable WITHIN one generation: re-deriving with the exposed epoch yields the same key', () => {
const c = fakeCrypto()
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
// The browser re-derives from the SAME (secret, sessionId, alg, epoch) carried with the ring buffer.
const browser = fakeCrypto()
const browserKey = browser.deriveContentKey({
hostContentSecret: SECRET,
sessionId: 'sess-1',
alg: 'aes-256-gcm',
epoch: sealer.epoch,
}) as unknown as Uint8Array
expect(Buffer.from(browserKey).equals(Buffer.from(c.keys[0]!))).toBe(true)
})
it('a different sessionId → a different key (per-session separation)', () => {
const c = fakeCrypto()
createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
createReplaySealer(SECRET, 'sess-2', 'aes-256-gcm', c)
expect(c.derivations[0]!.sessionId).not.toBe(c.derivations[1]!.sessionId)
})
it('F6 regression: two generations for the SAME (secret, sessionId, alg) get DIFFERENT epochs → DIFFERENT keys, so seq=0 never collides', () => {
const c = fakeCrypto()
const gen1 = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
const gen2 = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
// Fresh epoch per generation…
expect(gen2.epoch).not.toBe(gen1.epoch)
// …therefore distinct K_content even though (secret, sessionId, alg) are identical…
expect(Buffer.from(c.keys[1]!).equals(Buffer.from(c.keys[0]!))).toBe(false)
// …so the seq=0 seal of generation 2 uses a DIFFERENT key than the seq=0 seal of generation 1
// (this is exactly the (key, nonce) reuse F6 prevents — same nonce, but a fresh key).
const s1 = gen1.seal(new Uint8Array([0x41, 0x42, 0x43]))
const s2 = gen2.seal(new Uint8Array([0x41, 0x42, 0x43]))
expect(s1.seq).toBe(0n)
expect(s2.seq).toBe(0n)
expect(s1.nonce).toEqual(s2.nonce) // same deterministic nonce (seq=0)…
})
it('emits a monotonic seq (INV13) and never leaks the plaintext marker (INV2)', () => {
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto())
const marker = new TextEncoder().encode('SECRET-MARKER')
const e0 = sealer.seal(marker)
const e1 = sealer.seal(marker)
expect(e0.seq).toBe(0n)
expect(e1.seq).toBe(1n)
expect(Buffer.from(e0.ciphertext).includes(Buffer.from(marker))).toBe(false)
})
it('replay seal is DISTINCT from a live h2c seal for the same plaintext (FIX 3)', () => {
const replay = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto())
// model a live seal with a different key byte (0x11 is below the 0x300x66 hex-digit epoch prefix)
const liveKey = new Uint8Array([0x11]) as unknown as AeadKey
const live = fakeCrypto().sealReplayFrame(liveKey, 0n, new Uint8Array([0x41, 0x42]))
const rep = replay.seal(new Uint8Array([0x41, 0x42]))
expect(Buffer.from(rep.ciphertext).equals(Buffer.from(live.ciphertext))).toBe(false)
})
it('the hostContentSecret is never mutated', () => {
const secret = new Uint8Array([9, 9, 9])
const spy = vi.fn()
createReplaySealer(secret, 's', 'aes-256-gcm', {
deriveContentKey: (p) => {
spy(p.hostContentSecret)
return new Uint8Array([1]) as unknown as AeadKey
},
sealReplayFrame: (_k, seq, pt) => ({ seq, nonce: new Uint8Array(), ciphertext: pt, tag: new Uint8Array() }),
})
expect([...secret]).toEqual([9, 9, 9])
})
})