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:
@@ -13,7 +13,19 @@
|
|||||||
* the frozen relay-contracts signatures — production wiring passes the relay-e2e impls verbatim.
|
* the frozen relay-contracts signatures — production wiring passes the relay-e2e impls verbatim.
|
||||||
* `hostContentSecret` comes from Keystore.loadContentSecret() (T3); NEVER the ephemeral key,
|
* `hostContentSecret` comes from Keystore.loadContentSecret() (T3); NEVER the ephemeral key,
|
||||||
* NEVER logged, NEVER sent to the relay (INV2/INV9).
|
* 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'
|
import type { AeadAlg, AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts'
|
||||||
|
|
||||||
/** The two §4.4 replay primitives, typed to the frozen relay-contracts signatures (P4 impls). */
|
/** 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 {
|
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. */
|
/** K_content seal with monotonic seq per session (INV13); NOT the live h2c frame. */
|
||||||
seal(plaintext: Uint8Array): E2EEnvelope
|
seal(plaintext: Uint8Array): E2EEnvelope
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a per-(host, session) replay sealer. K_content is derived ONCE from
|
* Build a per-(host, session) replay sealer for ONE generation. A FRESH `epoch` is minted per call
|
||||||
* { hostContentSecret, sessionId, alg }; seq is strictly monotonic from 0 (INV13).
|
* 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(
|
export function createReplaySealer(
|
||||||
hostContentSecret: Uint8Array,
|
hostContentSecret: Uint8Array,
|
||||||
@@ -37,9 +56,11 @@ export function createReplaySealer(
|
|||||||
alg: AeadAlg,
|
alg: AeadAlg,
|
||||||
crypto: ReplayCrypto,
|
crypto: ReplayCrypto,
|
||||||
): ReplaySealer {
|
): ReplaySealer {
|
||||||
const key = crypto.deriveContentKey({ hostContentSecret, sessionId, alg })
|
const epoch = randomUUID()
|
||||||
|
const key = crypto.deriveContentKey({ hostContentSecret, sessionId, alg, epoch })
|
||||||
let seq = 0n
|
let seq = 0n
|
||||||
return {
|
return {
|
||||||
|
epoch,
|
||||||
seal(plaintext: Uint8Array): E2EEnvelope {
|
seal(plaintext: Uint8Array): E2EEnvelope {
|
||||||
const env = crypto.sealReplayFrame(key, seq, plaintext)
|
const env = crypto.sealReplayFrame(key, seq, plaintext)
|
||||||
seq += 1n
|
seq += 1n
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ describe('createE2ETransform (T15)', () => {
|
|||||||
function fakeReplay(): ReplaySealer & { calls: number } {
|
function fakeReplay(): ReplaySealer & { calls: number } {
|
||||||
const r = {
|
const r = {
|
||||||
calls: 0,
|
calls: 0,
|
||||||
|
epoch: 'test-epoch',
|
||||||
seal(_pt: Uint8Array) {
|
seal(_pt: Uint8Array) {
|
||||||
r.calls += 1
|
r.calls += 1
|
||||||
return { seq: 0n, nonce: new Uint8Array(), ciphertext: new Uint8Array([0xde]), tag: new Uint8Array() }
|
return { seq: 0n, nonce: new Uint8Array(), ciphertext: new Uint8Array([0xde]), tag: new Uint8Array() }
|
||||||
|
|||||||
@@ -2,14 +2,24 @@ import { describe, expect, it, vi } from 'vitest'
|
|||||||
import type { AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts'
|
import type { AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts'
|
||||||
import { createReplaySealer, type ReplayCrypto } from '../src/e2e/replaySeal.js'
|
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 derivations: ReplayKeyParams[] = []
|
||||||
|
const keys: Uint8Array[] = []
|
||||||
return {
|
return {
|
||||||
derivations,
|
derivations,
|
||||||
|
keys,
|
||||||
deriveContentKey(params: ReplayKeyParams): AeadKey {
|
deriveContentKey(params: ReplayKeyParams): AeadKey {
|
||||||
derivations.push(params)
|
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
|
return tag as unknown as AeadKey
|
||||||
},
|
},
|
||||||
sealReplayFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope {
|
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])
|
const SECRET = new Uint8Array([1, 2, 3, 4])
|
||||||
|
|
||||||
describe('createReplaySealer (T19, FIX 3)', () => {
|
describe('createReplaySealer (T19, FIX 3)', () => {
|
||||||
it('derives K_content deterministically from (secret, sessionId, alg)', () => {
|
it('folds the exposed per-generation epoch into K_content, deriving it exactly once', () => {
|
||||||
const c1 = fakeCrypto()
|
const c = fakeCrypto()
|
||||||
createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c1)
|
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
|
||||||
const c2 = fakeCrypto()
|
expect(c.derivations).toHaveLength(1)
|
||||||
createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c2)
|
expect(c.derivations[0]!.epoch).toBe(sealer.epoch)
|
||||||
expect(c1.derivations[0]).toEqual(c2.derivations[0])
|
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)', () => {
|
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)
|
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)', () => {
|
it('emits a monotonic seq (INV13) and never leaks the plaintext marker (INV2)', () => {
|
||||||
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto())
|
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto())
|
||||||
const marker = new TextEncoder().encode('SECRET-MARKER')
|
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)', () => {
|
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())
|
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 liveKey = new Uint8Array([0x11]) as unknown as AeadKey
|
||||||
const live = fakeCrypto().sealReplayFrame(liveKey, 0n, new Uint8Array([0x41, 0x42]))
|
const live = fakeCrypto().sealReplayFrame(liveKey, 0n, new Uint8Array([0x41, 0x42]))
|
||||||
const rep = replay.seal(new Uint8Array([0x41, 0x42]))
|
const rep = replay.seal(new Uint8Array([0x41, 0x42]))
|
||||||
|
|||||||
@@ -117,10 +117,23 @@ export interface AuthorizedDeviceContext {
|
|||||||
readonly hostContentSecret: Uint8Array // §4.5 host-scoped secret; NEVER logged/sent to relay
|
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 {
|
export interface ReplayKeyParams {
|
||||||
readonly hostContentSecret: Uint8Array
|
readonly hostContentSecret: Uint8Array
|
||||||
readonly sessionId: string
|
readonly sessionId: string
|
||||||
readonly alg: AeadAlg
|
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
|
export const REPLAY_KDF_INFO = 'relay-e2e/replay/v1' as const
|
||||||
|
|||||||
@@ -21,12 +21,36 @@ import { openFrame, sealFrame } from './session.js'
|
|||||||
|
|
||||||
const encoder = new TextEncoder()
|
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 {
|
export function deriveContentKey(p: ReplayKeyParams): AeadKey {
|
||||||
const raw = hkdf(
|
const raw = hkdf(
|
||||||
sha256,
|
sha256,
|
||||||
p.hostContentSecret,
|
p.hostContentSecret,
|
||||||
encoder.encode(p.sessionId),
|
replaySalt(p.sessionId, p.epoch),
|
||||||
encoder.encode(REPLAY_KDF_INFO),
|
encoder.encode(REPLAY_KDF_INFO),
|
||||||
AEAD_KEY_BYTES,
|
AEAD_KEY_BYTES,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ describe('T11 multi-device adapters (authenticated channel, NOT a QR)', () => {
|
|||||||
expect(result.aead).toBe('xchacha20-poly1305')
|
expect(result.aead).toBe('xchacha20-poly1305')
|
||||||
expect(label).toBeTruthy()
|
expect(label).toBeTruthy()
|
||||||
}
|
}
|
||||||
const kA = 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' })
|
const kB = deriveContentKey({ hostContentSecret: secret, sessionId: 's', alg: 'aes-256-gcm', epoch: 'gen-0' })
|
||||||
expect(bytesToHex(unwrapAeadKey(kA).raw)).toBe(bytesToHex(unwrapAeadKey(kB).raw))
|
expect(bytesToHex(unwrapAeadKey(kA).raw)).toBe(bytesToHex(unwrapAeadKey(kB).raw))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,16 @@ import { AeadOpenError } from '../src/errors.js'
|
|||||||
import { deriveContentKey, openReplayCiphertext, sealReplayFrame } from '../src/replay-key.js'
|
import { deriveContentKey, openReplayCiphertext, sealReplayFrame } from '../src/replay-key.js'
|
||||||
import { bytesToHex, fromUtf8, utf8 } from './helpers.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,
|
hostContentSecret: secret,
|
||||||
sessionId,
|
sessionId,
|
||||||
alg,
|
alg,
|
||||||
|
epoch,
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('T10 recoverable replay content-key', () => {
|
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)
|
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', () => {
|
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.
|
// The unwrap mechanism is P5/P2; P4 asserts derivation is GATED on having the unwrapped secret.
|
||||||
const revokedUnwrap = (): Uint8Array => {
|
const revokedUnwrap = (): Uint8Array => {
|
||||||
|
|||||||
@@ -16,11 +16,23 @@ import { createApiClient } from '../api-client'
|
|||||||
import { mountPreviewGrid } from '../preview-grid'
|
import { mountPreviewGrid } from '../preview-grid'
|
||||||
import type { ReplaySource } from '../preview-client'
|
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(
|
async function loadReplay(
|
||||||
_hostId: string,
|
_hostId: string,
|
||||||
): Promise<{ replay: ReplaySource; hostContentSecret: Uint8Array }> {
|
): Promise<{ replay: ReplaySource; hostContentSecret: Uint8Array }> {
|
||||||
// TODO(P5/P1): fetch ciphertext replay + hostContentSecret (post auth/step-up). Not yet available.
|
// TODO(P5/P1/P2): fetch ciphertext replay + hostContentSecret (post auth/step-up) AND the real
|
||||||
throw new Error('replay source not yet wired (pending P5/P1 endpoints)')
|
// 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 {
|
function boot(): void {
|
||||||
|
|||||||
@@ -6,10 +6,11 @@
|
|||||||
* Ring-buffer replay survives a reload because the agent (P2) sealed each stored frame with
|
* 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
|
* `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
|
* `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
|
* `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",
|
* `openReplayCiphertext`. A wrong/ephemeral key — or a `ReplaySource.epoch` that doesn't match the
|
||||||
* never a torn/garbled screen (cross-host/session isolation, INV1).
|
* 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
|
* The §4.4 crypto (`deriveContentKey`/`openReplayCiphertext`) is imported from `relay-e2e` and
|
||||||
* injected — cited verbatim, never re-implemented. `hostContentSecret`/`K_content` are transient in
|
* injected — cited verbatim, never re-implemented. `hostContentSecret`/`K_content` are transient in
|
||||||
@@ -17,10 +18,17 @@
|
|||||||
*/
|
*/
|
||||||
import type { AeadAlg, AeadKey, ReplayKeyParams } from 'relay-contracts'
|
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 {
|
export interface ReplaySource {
|
||||||
readonly sessionId: string
|
readonly sessionId: string
|
||||||
readonly alg: AeadAlg // negotiated aead (matches how P2 sealed the replay)
|
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
|
readonly frames: readonly Uint8Array[] // stored DATA payloads, each a sealReplayFrame envelope
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +93,7 @@ export function mountPreviewClient(
|
|||||||
hostContentSecret,
|
hostContentSecret,
|
||||||
sessionId: replay.sessionId,
|
sessionId: replay.sessionId,
|
||||||
alg: replay.alg,
|
alg: replay.alg,
|
||||||
|
epoch: replay.epoch, // F6: bind the key to THIS generation; a mismatched epoch → wrong key.
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
showUnavailable()
|
showUnavailable()
|
||||||
|
|||||||
@@ -52,10 +52,12 @@ describe('default (real-xterm) loaders behind the DI seam', () => {
|
|||||||
|
|
||||||
it('mountPreviewClient renders via the default read-only xterm loader', async () => {
|
it('mountPreviewClient renders via the default read-only xterm loader', async () => {
|
||||||
const secret = randomBytes(32)
|
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 = {
|
const replay: ReplaySource = {
|
||||||
sessionId: 's',
|
sessionId: 's',
|
||||||
alg: 'aes-256-gcm',
|
alg: 'aes-256-gcm',
|
||||||
|
epoch,
|
||||||
frames: [encodeEnvelope(sealReplayFrame(k, 0n, new TextEncoder().encode('DEFAULT-XTERM')))],
|
frames: [encodeEnvelope(sealReplayFrame(k, 0n, new TextEncoder().encode('DEFAULT-XTERM')))],
|
||||||
}
|
}
|
||||||
const card = document.createElement('div')
|
const card = document.createElement('div')
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ describe('preview-client unavailable path', () => {
|
|||||||
const card = document.createElement('div')
|
const card = document.createElement('div')
|
||||||
const client = mountPreviewClient(
|
const client = mountPreviewClient(
|
||||||
card,
|
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),
|
new Uint8Array(32),
|
||||||
{ cols: 80, rows: 24 },
|
{ cols: 80, rows: 24 },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,16 +11,38 @@ import {
|
|||||||
const HOST_SECRET = randomBytes(32)
|
const HOST_SECRET = randomBytes(32)
|
||||||
const SESSION_ID = 'sess-1'
|
const SESSION_ID = 'sess-1'
|
||||||
const ALG = 'aes-256-gcm' as const
|
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. */
|
interface SealOpts {
|
||||||
function sealedReplay(lines: readonly string[], secret = HOST_SECRET): ReplaySource {
|
readonly secret?: Uint8Array
|
||||||
const k = deriveContentKey({ hostContentSecret: secret, sessionId: SESSION_ID, alg: ALG })
|
/** 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) =>
|
const frames = lines.map((line, i) =>
|
||||||
// sealReplayFrame → E2EEnvelope, encoded to a DATA payload the browser opens.
|
// sealReplayFrame → E2EEnvelope, encoded to a DATA payload the browser opens.
|
||||||
// openReplayCiphertext decodes+opens; we mirror the encode via the session envelope codec.
|
// openReplayCiphertext decodes+opens; we mirror the encode via the session envelope codec.
|
||||||
encodeReplayFrame(sealReplayFrame(k, BigInt(i), new TextEncoder().encode(line))),
|
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.
|
// 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
|
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 () => {
|
it('read-only: the preview terminal exposes NO input path (no onData/send)', async () => {
|
||||||
const card = document.createElement('div')
|
const card = document.createElement('div')
|
||||||
const term = mockTerminal()
|
const term = mockTerminal()
|
||||||
|
|||||||
@@ -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 {
|
function sealed(sessionId: string, secret: Uint8Array, line: string): ReplaySource {
|
||||||
const k = deriveContentKey({ hostContentSecret: secret, sessionId, alg: ALG })
|
const k = deriveContentKey({ hostContentSecret: secret, sessionId, alg: ALG, epoch: EPOCH })
|
||||||
return { sessionId, alg: ALG, frames: [encodeEnvelope(sealReplayFrame(k, 0n, new TextEncoder().encode(line)))] }
|
return {
|
||||||
|
sessionId,
|
||||||
|
alg: ALG,
|
||||||
|
epoch: EPOCH,
|
||||||
|
frames: [encodeEnvelope(sealReplayFrame(k, 0n, new TextEncoder().encode(line)))],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeApi(hosts: readonly HostRecord[]): ApiClient {
|
function fakeApi(hosts: readonly HostRecord[]): ApiClient {
|
||||||
|
|||||||
Reference in New Issue
Block a user