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

@@ -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

View File

@@ -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() }

View File

@@ -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 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(`${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 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]))

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

View File

@@ -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,
)

View File

@@ -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))
})

View File

@@ -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 => {

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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')

View File

@@ -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 },
{

View File

@@ -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()

View File

@@ -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 {