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.
This commit is contained in:
Yaojia Wang
2026-07-02 16:41:19 +02:00
parent a09c131539
commit 3020184054
13 changed files with 243 additions and 33 deletions

View File

@@ -117,10 +117,23 @@ export interface AuthorizedDeviceContext {
readonly hostContentSecret: Uint8Array // §4.5 host-scoped secret; NEVER logged/sent to relay
}
/** FIX 3 replay content-key derivation params (§4.4). */
/**
* FIX 3 replay content-key derivation params (§4.4).
*
* FIX 3b / F6 (anti-nonce-reuse): K_content = HKDF(hostContentSecret, salt=sessionId, info=const)
* was byte-identical for a given (host, sessionId), yet the agent-side sealer resets its
* deterministic-nonce seq to 0 on every reconstruction (restart / re-attach). Two sealer
* GENERATIONS would therefore seal distinct plaintext under the SAME (key, nonce) — a
* catastrophic AEAD reuse. The per-generation `epoch` is folded into K_content so a restart /
* new generation yields a FRESH key even for the same (hostContentSecret, sessionId); seq=0 can
* never collide across generations. Recoverability WITHIN one generation (same epoch ⇒ same key)
* is preserved.
*/
export interface ReplayKeyParams {
readonly hostContentSecret: Uint8Array
readonly sessionId: string
readonly alg: AeadAlg
readonly epoch: string // FIX 3b / F6: fresh per-sealer-generation diversifier folded into K_content;
// NON-SECRET, carried with the ring buffer so the browser re-derives the key.
}
export const REPLAY_KDF_INFO = 'relay-e2e/replay/v1' as const