From 3020184054b798cb1e760ad0b3b6fceb809a4990 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 2 Jul 2026 16:41:19 +0200 Subject: [PATCH] fix(relay): close F6 replay K_content nonce reuse via per-generation epoch-in-key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- agent/src/e2e/replaySeal.ts | 27 +++++++++-- agent/test/hostEndpoint.test.ts | 1 + agent/test/replaySeal.test.ts | 61 +++++++++++++++++++++---- relay-contracts/src/e2e/types.ts | 15 +++++- relay-e2e/src/replay-key.ts | 28 +++++++++++- relay-e2e/test/keystore.test.ts | 4 +- relay-e2e/test/replay-key.test.ts | 31 ++++++++++++- relay-web/src/entry/manage-page.ts | 16 ++++++- relay-web/src/preview-client.ts | 17 +++++-- relay-web/test/default-terminal.test.ts | 4 +- relay-web/test/extra-coverage.test.ts | 2 +- relay-web/test/preview-client.test.ts | 59 ++++++++++++++++++++++-- relay-web/test/preview-grid.test.ts | 11 ++++- 13 files changed, 243 insertions(+), 33 deletions(-) diff --git a/agent/src/e2e/replaySeal.ts b/agent/src/e2e/replaySeal.ts index 6180375..3ed135d 100644 --- a/agent/src/e2e/replaySeal.ts +++ b/agent/src/e2e/replaySeal.ts @@ -13,7 +13,19 @@ * the frozen relay-contracts signatures — production wiring passes the relay-e2e impls verbatim. * `hostContentSecret` comes from Keystore.loadContentSecret() (T3); NEVER the ephemeral key, * NEVER logged, NEVER sent to the relay (INV2/INV9). + * + * PER-GENERATION EPOCH (F6 fix): K_content = HKDF(hostContentSecret, salt=sessionId, info=const) is + * byte-identical for a given (host, sessionId) and RECOVERABLE by design — but the deterministic seal + * nonce is seq, which resets to 0 on every sealer reconstruction (restart / re-attach). Two sealer + * GENERATIONS would therefore seal DISTINCT plaintext under the SAME (key, nonce) → catastrophic AEAD + * reuse. Each `createReplaySealer` call mints a FRESH, NON-SECRET `epoch` (randomUUID) that is folded + * into K_content derivation, so a restart / new generation yields a FRESH key even for the same + * (hostContentSecret, sessionId); seq=0 can never collide across generations. The epoch is exposed on + * the sealer so the wiring can persist it with the ring buffer / replay stream and serve it to the + * browser, which re-derives the matching key. Recoverability WITHIN one generation (same epoch ⇒ same + * key) is preserved. */ +import { randomUUID } from 'node:crypto' import type { AeadAlg, AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts' /** The two §4.4 replay primitives, typed to the frozen relay-contracts signatures (P4 impls). */ @@ -23,13 +35,20 @@ export interface ReplayCrypto { } export interface ReplaySealer { + /** + * The fresh, NON-SECRET per-generation epoch folded into K_content (F6). The wiring persists it + * with the ring buffer / replay stream so the browser re-derives the matching key. + */ + readonly epoch: string /** K_content seal with monotonic seq per session (INV13); NOT the live h2c frame. */ seal(plaintext: Uint8Array): E2EEnvelope } /** - * Build a per-(host, session) replay sealer. K_content is derived ONCE from - * { hostContentSecret, sessionId, alg }; seq is strictly monotonic from 0 (INV13). + * Build a per-(host, session) replay sealer for ONE generation. A FRESH `epoch` is minted per call + * and folded into K_content, which is derived ONCE from { hostContentSecret, sessionId, alg, epoch }; + * seq is strictly monotonic from 0 (INV13). A restart / re-attach constructs a NEW generation with a + * NEW epoch ⇒ a FRESH key, so seq=0 never collides across generations (F6). */ export function createReplaySealer( hostContentSecret: Uint8Array, @@ -37,9 +56,11 @@ export function createReplaySealer( alg: AeadAlg, crypto: ReplayCrypto, ): ReplaySealer { - const key = crypto.deriveContentKey({ hostContentSecret, sessionId, alg }) + const epoch = randomUUID() + const key = crypto.deriveContentKey({ hostContentSecret, sessionId, alg, epoch }) let seq = 0n return { + epoch, seal(plaintext: Uint8Array): E2EEnvelope { const env = crypto.sealReplayFrame(key, seq, plaintext) seq += 1n diff --git a/agent/test/hostEndpoint.test.ts b/agent/test/hostEndpoint.test.ts index a9c348e..7c42d61 100644 --- a/agent/test/hostEndpoint.test.ts +++ b/agent/test/hostEndpoint.test.ts @@ -107,6 +107,7 @@ describe('createE2ETransform (T15)', () => { function fakeReplay(): ReplaySealer & { calls: number } { const r = { calls: 0, + epoch: 'test-epoch', seal(_pt: Uint8Array) { r.calls += 1 return { seq: 0n, nonce: new Uint8Array(), ciphertext: new Uint8Array([0xde]), tag: new Uint8Array() } diff --git a/agent/test/replaySeal.test.ts b/agent/test/replaySeal.test.ts index caaaf15..3601481 100644 --- a/agent/test/replaySeal.test.ts +++ b/agent/test/replaySeal.test.ts @@ -2,14 +2,24 @@ 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: key = tagged secret‖sessionId; ciphertext = plaintext XOR keyByte (marker hidden). */ -function fakeCrypto(): ReplayCrypto & { derivations: ReplayKeyParams[] } { +/** + * 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 0x30–0x66 → 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(`${Buffer.from(params.hostContentSecret).toString('hex')}:${params.sessionId}`) + 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 { @@ -23,12 +33,26 @@ function fakeCrypto(): ReplayCrypto & { derivations: ReplayKeyParams[] } { const SECRET = new Uint8Array([1, 2, 3, 4]) describe('createReplaySealer (T19, FIX 3)', () => { - it('derives K_content deterministically from (secret, sessionId, alg)', () => { - const c1 = fakeCrypto() - createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c1) - const c2 = fakeCrypto() - createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c2) - expect(c1.derivations[0]).toEqual(c2.derivations[0]) + 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)', () => { @@ -38,6 +62,23 @@ describe('createReplaySealer (T19, FIX 3)', () => { 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') @@ -50,7 +91,7 @@ describe('createReplaySealer (T19, FIX 3)', () => { 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 + // model a live seal with a different key byte (0x11 is below the 0x30–0x66 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])) diff --git a/relay-contracts/src/e2e/types.ts b/relay-contracts/src/e2e/types.ts index e754d34..99bdc94 100644 --- a/relay-contracts/src/e2e/types.ts +++ b/relay-contracts/src/e2e/types.ts @@ -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 diff --git a/relay-e2e/src/replay-key.ts b/relay-e2e/src/replay-key.ts index a54a0ab..5deccb3 100644 --- a/relay-e2e/src/replay-key.ts +++ b/relay-e2e/src/replay-key.ts @@ -21,12 +21,36 @@ import { openFrame, sealFrame } from './session.js' const encoder = new TextEncoder() -/** HKDF(hostContentSecret, salt=sessionId, info=REPLAY_KDF_INFO) → per-session, per-host content key. */ +/** + * ASCII unit separator (0x1f). Domain-separates sessionId ‖ epoch inside the HKDF salt so + * distinct (sessionId, epoch) pairs can never alias via concatenation. Neither field contains + * it: sessionId is an opaque id and epoch is a generated non-secret diversifier. + */ +const SALT_FIELD_SEPARATOR = 0x1f + +/** salt = utf8(sessionId) ‖ 0x1f ‖ utf8(epoch) — unambiguous per-session, per-generation binding. */ +function replaySalt(sessionId: string, epoch: string): Uint8Array { + const sessionBytes = encoder.encode(sessionId) + const epochBytes = encoder.encode(epoch) + const salt = new Uint8Array(sessionBytes.length + 1 + epochBytes.length) + salt.set(sessionBytes, 0) + salt[sessionBytes.length] = SALT_FIELD_SEPARATOR + salt.set(epochBytes, sessionBytes.length + 1) + return salt +} + +/** + * HKDF(hostContentSecret, salt=sessionId ‖ 0x1f ‖ epoch, info=REPLAY_KDF_INFO) → per-session, + * per-host, per-generation content key (F6). A fresh `epoch` per sealer generation yields a fresh + * key even for the same (hostContentSecret, sessionId), so a seq=0 reset after restart/re-attach + * can never collide with a prior generation's (key, nonce). Recoverability WITHIN one generation is + * preserved: same (secret, sessionId, alg, epoch) ⇒ byte-identical key. + */ export function deriveContentKey(p: ReplayKeyParams): AeadKey { const raw = hkdf( sha256, p.hostContentSecret, - encoder.encode(p.sessionId), + replaySalt(p.sessionId, p.epoch), encoder.encode(REPLAY_KDF_INFO), AEAD_KEY_BYTES, ) diff --git a/relay-e2e/test/keystore.test.ts b/relay-e2e/test/keystore.test.ts index 343a701..73d6e0d 100644 --- a/relay-e2e/test/keystore.test.ts +++ b/relay-e2e/test/keystore.test.ts @@ -37,8 +37,8 @@ describe('T11 multi-device adapters (authenticated channel, NOT a QR)', () => { expect(result.aead).toBe('xchacha20-poly1305') expect(label).toBeTruthy() } - const kA = deriveContentKey({ hostContentSecret: secret, sessionId: 's', alg: 'aes-256-gcm' }) - const kB = deriveContentKey({ hostContentSecret: secret, sessionId: 's', alg: 'aes-256-gcm' }) + const kA = deriveContentKey({ hostContentSecret: secret, sessionId: 's', alg: 'aes-256-gcm', epoch: 'gen-0' }) + const kB = deriveContentKey({ hostContentSecret: secret, sessionId: 's', alg: 'aes-256-gcm', epoch: 'gen-0' }) expect(bytesToHex(unwrapAeadKey(kA).raw)).toBe(bytesToHex(unwrapAeadKey(kB).raw)) }) diff --git a/relay-e2e/test/replay-key.test.ts b/relay-e2e/test/replay-key.test.ts index 2e64d0f..adf5c0c 100644 --- a/relay-e2e/test/replay-key.test.ts +++ b/relay-e2e/test/replay-key.test.ts @@ -6,10 +6,16 @@ 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 => ({ +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', () => { @@ -48,6 +54,29 @@ describe('T10 recoverable replay content-key', () => { 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 => { diff --git a/relay-web/src/entry/manage-page.ts b/relay-web/src/entry/manage-page.ts index 436dc91..5670db7 100644 --- a/relay-web/src/entry/manage-page.ts +++ b/relay-web/src/entry/manage-page.ts @@ -16,11 +16,23 @@ import { createApiClient } from '../api-client' import { mountPreviewGrid } from '../preview-grid' import type { ReplaySource } from '../preview-client' +/** + * FIX 3b / F6 placeholder epoch. The real per-generation `epoch` MUST be served by P1/P2 alongside + * the ciphertext ring buffer (the agent stamps a fresh epoch each time it reconstructs the sealer and + * resets seq to 0). This constant only keeps the `ReplaySource` shape explicit until those endpoints + * land; the stub still throws, so no frame is ever decrypted under it. + */ +const PLACEHOLDER_EPOCH = 'PENDING_P1_P2_EPOCH' + async function loadReplay( _hostId: string, ): Promise<{ replay: ReplaySource; hostContentSecret: Uint8Array }> { - // TODO(P5/P1): fetch ciphertext replay + hostContentSecret (post auth/step-up). Not yet available. - throw new Error('replay source not yet wired (pending P5/P1 endpoints)') + // TODO(P5/P1/P2): fetch ciphertext replay + hostContentSecret (post auth/step-up) AND the real + // per-generation `epoch` (FIX 3b / F6) that the frames were sealed under. Until then the shape is: + // { replay: { sessionId, alg, epoch: PLACEHOLDER_EPOCH, frames }, hostContentSecret } + // Not yet available → throw so a card shows "unavailable" rather than a fabricated screen. + void PLACEHOLDER_EPOCH + throw new Error('replay source not yet wired (pending P5/P1/P2 endpoints)') } function boot(): void { diff --git a/relay-web/src/preview-client.ts b/relay-web/src/preview-client.ts index 618f66f..8cabf6b 100644 --- a/relay-web/src/preview-client.ts +++ b/relay-web/src/preview-client.ts @@ -6,10 +6,11 @@ * Ring-buffer replay survives a reload because the agent (P2) sealed each stored frame with * `sealReplayFrame` under the RECOVERABLE content key `K_content` — NOT the ephemeral live * `DirectionalKeys` (which are re-derived per handshake and lost on reload). The browser re-derives - * the SAME `K_content` via `deriveContentKey({ hostContentSecret, sessionId, alg })` (§4.4 FIX 3; + * the SAME `K_content` via `deriveContentKey({ hostContentSecret, sessionId, alg, epoch })` (§4.4 FIX 3; * `hostContentSecret` obtained via P5 after auth/step-up) and decrypts each payload with - * `openReplayCiphertext`. A wrong/ephemeral key throws (AEAD tag) → the card shows "unavailable", - * never a torn/garbled screen (cross-host/session isolation, INV1). + * `openReplayCiphertext`. A wrong/ephemeral key — or a `ReplaySource.epoch` that doesn't match the + * generation the frames were sealed under (FIX 3b / F6) — throws (AEAD tag) → the card shows + * "unavailable", never a torn/garbled screen (cross-host/session isolation, INV1). * * The §4.4 crypto (`deriveContentKey`/`openReplayCiphertext`) is imported from `relay-e2e` and * injected — cited verbatim, never re-implemented. `hostContentSecret`/`K_content` are transient in @@ -17,10 +18,17 @@ */ import type { AeadAlg, AeadKey, ReplayKeyParams } from 'relay-contracts' -/** Ciphertext ring-buffer replay for one host/session. */ +/** Ciphertext ring-buffer replay for one host/session (one sealer generation). */ export interface ReplaySource { readonly sessionId: string readonly alg: AeadAlg // negotiated aead (matches how P2 sealed the replay) + // FIX 3b / F6: the per-generation `epoch` under which THESE frames were sealed. The agent resets + // its deterministic-nonce seq to 0 on every reconstruction (restart/re-attach); folding a fresh + // epoch into K_content gives each generation a fresh key, so a seq=0 reset can never collide with + // a prior generation's (key, nonce). NON-SECRET — carried with the ring buffer so the browser + // re-derives the matching key. Frames from a DIFFERENT epoch derive a different key → AEAD tag + // fails → "unavailable" (never a torn screen). + readonly epoch: string readonly frames: readonly Uint8Array[] // stored DATA payloads, each a sealReplayFrame envelope } @@ -85,6 +93,7 @@ export function mountPreviewClient( hostContentSecret, sessionId: replay.sessionId, alg: replay.alg, + epoch: replay.epoch, // F6: bind the key to THIS generation; a mismatched epoch → wrong key. }) } catch { showUnavailable() diff --git a/relay-web/test/default-terminal.test.ts b/relay-web/test/default-terminal.test.ts index d88bc5f..562c751 100644 --- a/relay-web/test/default-terminal.test.ts +++ b/relay-web/test/default-terminal.test.ts @@ -52,10 +52,12 @@ describe('default (real-xterm) loaders behind the DI seam', () => { it('mountPreviewClient renders via the default read-only xterm loader', async () => { const secret = randomBytes(32) - const k = deriveContentKey({ hostContentSecret: secret, sessionId: 's', alg: 'aes-256-gcm' }) + const epoch = 'gen-1' // FIX 3b / F6: seal + declare under the same generation. + const k = deriveContentKey({ hostContentSecret: secret, sessionId: 's', alg: 'aes-256-gcm', epoch }) const replay: ReplaySource = { sessionId: 's', alg: 'aes-256-gcm', + epoch, frames: [encodeEnvelope(sealReplayFrame(k, 0n, new TextEncoder().encode('DEFAULT-XTERM')))], } const card = document.createElement('div') diff --git a/relay-web/test/extra-coverage.test.ts b/relay-web/test/extra-coverage.test.ts index f4715be..0e9765a 100644 --- a/relay-web/test/extra-coverage.test.ts +++ b/relay-web/test/extra-coverage.test.ts @@ -207,7 +207,7 @@ describe('preview-client unavailable path', () => { const card = document.createElement('div') const client = mountPreviewClient( card, - { sessionId: 's', alg: 'aes-256-gcm', frames: [new Uint8Array([1])] }, + { sessionId: 's', alg: 'aes-256-gcm', epoch: 'gen-1', frames: [new Uint8Array([1])] }, new Uint8Array(32), { cols: 80, rows: 24 }, { diff --git a/relay-web/test/preview-client.test.ts b/relay-web/test/preview-client.test.ts index f413854..238935e 100644 --- a/relay-web/test/preview-client.test.ts +++ b/relay-web/test/preview-client.test.ts @@ -11,16 +11,38 @@ import { const HOST_SECRET = randomBytes(32) const SESSION_ID = 'sess-1' const ALG = 'aes-256-gcm' as const +const EPOCH = 'gen-1' // FIX 3b / F6: the sealer generation these fixtures seal + declare under. -/** Seal a screen (as a P2 agent stub would) under K_content with sealReplayFrame. */ -function sealedReplay(lines: readonly string[], secret = HOST_SECRET): ReplaySource { - const k = deriveContentKey({ hostContentSecret: secret, sessionId: SESSION_ID, alg: ALG }) +interface SealOpts { + readonly secret?: Uint8Array + /** Epoch the FRAMES are actually sealed under (the key that encrypts). */ + readonly sealEpoch?: string + /** Epoch the ReplaySource DECLARES (the key the browser will re-derive). Defaults to sealEpoch. */ + readonly declaredEpoch?: string +} + +/** + * Seal a screen (as a P2 agent stub would) under K_content with sealReplayFrame. Recoverability + * holds because the frames are sealed with the SAME (secret, sessionId, alg, epoch) the returned + * ReplaySource declares. For the F6 regression, `sealEpoch` and `declaredEpoch` are set apart so the + * browser derives a different key than the frames were sealed under. + */ +function sealedReplay(lines: readonly string[], opts: SealOpts = {}): ReplaySource { + const secret = opts.secret ?? HOST_SECRET + const sealEpoch = opts.sealEpoch ?? EPOCH + const declaredEpoch = opts.declaredEpoch ?? sealEpoch + const k = deriveContentKey({ + hostContentSecret: secret, + sessionId: SESSION_ID, + alg: ALG, + epoch: sealEpoch, + }) const frames = lines.map((line, i) => // sealReplayFrame → E2EEnvelope, encoded to a DATA payload the browser opens. // openReplayCiphertext decodes+opens; we mirror the encode via the session envelope codec. encodeReplayFrame(sealReplayFrame(k, BigInt(i), new TextEncoder().encode(line))), ) - return { sessionId: SESSION_ID, alg: ALG, frames } + return { sessionId: SESSION_ID, alg: ALG, epoch: declaredEpoch, frames } } // The replay codec: relay-e2e's openReplayCiphertext expects the encoded envelope bytes. @@ -67,6 +89,35 @@ describe('mountPreviewClient (T9)', () => { expect(term.written).toEqual([]) // never wrote a torn screen }) + it('F6: a ReplaySource epoch that does NOT match the sealed generation → "unavailable", never a torn screen', async () => { + const card = document.createElement('div') + const term = mockTerminal() + // Frames sealed under generation "gen-1", but the ReplaySource declares a DIFFERENT epoch + // ("gen-2") — as if the ring buffer were mislabeled with the wrong generation. The browser + // re-derives a different K_content → AEAD tag fails → unavailable (no garbled render). + const replay = sealedReplay(['stale-screen'], { sealEpoch: 'gen-1', declaredEpoch: 'gen-2' }) + const client = mountPreviewClient(card, replay, HOST_SECRET, { cols: 80, rows: 24 }, { + ...realDeps, + createTerminal: () => term, + }) + await client.render() + expect(card.querySelector('.preview-unavailable')?.textContent).toBe('unavailable') + expect(term.written).toEqual([]) // never wrote a torn screen from the wrong generation + }) + + it('F6: a matching per-generation epoch preserves recoverability (frames render)', async () => { + const card = document.createElement('div') + const term = mockTerminal() + // sealEpoch === declaredEpoch (both "gen-7") ⇒ same key within one generation ⇒ replay recovers. + const replay = sealedReplay(['line-A', 'line-B'], { sealEpoch: 'gen-7', declaredEpoch: 'gen-7' }) + const client = mountPreviewClient(card, replay, HOST_SECRET, { cols: 80, rows: 24 }, { + ...realDeps, + createTerminal: () => term, + }) + await client.render() + expect(term.written).toEqual(['line-A', 'line-B']) + }) + it('read-only: the preview terminal exposes NO input path (no onData/send)', async () => { const card = document.createElement('div') const term = mockTerminal() diff --git a/relay-web/test/preview-grid.test.ts b/relay-web/test/preview-grid.test.ts index 3bb47da..b48bd6d 100644 --- a/relay-web/test/preview-grid.test.ts +++ b/relay-web/test/preview-grid.test.ts @@ -27,9 +27,16 @@ function host(sub: string): HostRecord { } } +const EPOCH = 'gen-1' // FIX 3b / F6: per-generation key diversifier (seal + declare under the same one). + function sealed(sessionId: string, secret: Uint8Array, line: string): ReplaySource { - const k = deriveContentKey({ hostContentSecret: secret, sessionId, alg: ALG }) - return { sessionId, alg: ALG, frames: [encodeEnvelope(sealReplayFrame(k, 0n, new TextEncoder().encode(line)))] } + const k = deriveContentKey({ hostContentSecret: secret, sessionId, alg: ALG, epoch: EPOCH }) + return { + sessionId, + alg: ALG, + epoch: EPOCH, + frames: [encodeEnvelope(sealReplayFrame(k, 0n, new TextEncoder().encode(line)))], + } } function fakeApi(hosts: readonly HostRecord[]): ApiClient {