/** * T9 (v0.10) — client-side preview rendering. Under E2E the relay CANNOT render a screen it cannot * read (server previews die); the authorized, key-holding browser decrypts a CIPHERTEXT replay and * renders a READ-ONLY xterm. * * 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, epoch })` (§4.4 FIX 3; * `hostContentSecret` obtained via P5 after auth/step-up) and decrypts each payload with * `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 * memory: NEVER persisted or logged (INV5/INV9). The preview has NO input wiring (read-only). */ import type { AeadAlg, AeadKey, ReplayKeyParams } from 'relay-contracts' /** 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 } /** Read-only terminal surface — NO `onData`/input path exists (structural read-only guarantee). */ export interface ReadonlyTerminalLike { open(container: HTMLElement): void write(data: string): void dispose(): void } /** Injected §4.4 replay crypto (from relay-e2e) + a read-only terminal factory. */ export interface PreviewDeps { deriveContentKey(p: ReplayKeyParams): AeadKey openReplayCiphertext(k: AeadKey, dataPayload: Uint8Array): Uint8Array createTerminal?: (dims: { cols: number; rows: number }) => ReadonlyTerminalLike } export interface PreviewClient { render(): Promise dispose(): void } const decoder = new TextDecoder() async function defaultReadonlyTerminal(dims: { cols: number rows: number }): Promise { const { Terminal } = await import('@xterm/xterm') // disableStdin: the preview is read-only — it can never become a covert input channel. const term = new Terminal({ cols: dims.cols, rows: dims.rows, disableStdin: true }) return { open: (el) => term.open(el), write: (data) => term.write(data), dispose: () => term.dispose(), } } export function mountPreviewClient( card: HTMLElement, replay: ReplaySource, hostContentSecret: Uint8Array, dims: { cols: number; rows: number }, deps: PreviewDeps, ): PreviewClient { let term: ReadonlyTerminalLike | null = null let disposed = false function showUnavailable(): void { const msg = document.createElement('p') msg.className = 'preview-unavailable' msg.textContent = 'unavailable' card.replaceChildren(msg) } async function render(): Promise { // Derive K_content ONCE, fresh from the host-scoped secret (no in-memory ephemeral key) — // this is why replay survives a reload where the forward-secret live keys cannot. let kContent: AeadKey try { kContent = deps.deriveContentKey({ hostContentSecret, sessionId: replay.sessionId, alg: replay.alg, epoch: replay.epoch, // F6: bind the key to THIS generation; a mismatched epoch → wrong key. }) } catch { showUnavailable() return } // Decrypt every stored ciphertext BEFORE mounting a terminal; a wrong/ephemeral key (or a // host-mismatched secret) makes openReplayCiphertext throw → "unavailable", never a torn screen. const chunks: string[] = [] for (const frame of replay.frames) { try { chunks.push(decoder.decode(deps.openReplayCiphertext(kContent, frame))) } catch { showUnavailable() return } } if (disposed) return const factory = deps.createTerminal ?? ((d) => makeSyncPending(defaultReadonlyTerminal(d))) term = factory(dims) term.open(card) for (const chunk of chunks) term.write(chunk) } return { render, dispose(): void { disposed = true if (term) term.dispose() }, } } /** * Bridge the async default terminal loader into the synchronous factory shape. The real path * (production) awaits xterm before writing; tests always inject a synchronous mock so this branch * is not exercised there. */ function makeSyncPending(pending: Promise): ReadonlyTerminalLike { let resolved: ReadonlyTerminalLike | null = null const queue: string[] = [] void pending.then((t) => { resolved = t for (const c of queue) t.write(c) }) return { open: (el) => void pending.then((t) => t.open(el)), write: (data) => (resolved ? resolved.write(data) : void queue.push(data)), dispose: () => void pending.then((t) => t.dispose()), } }