Files
web-terminal/relay-web/src/entry/manage-page.ts
Yaojia Wang 3020184054 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.
2026-07-02 16:41:19 +02:00

51 lines
2.3 KiB
TypeScript

/**
* manage.html entry (v0.10) — CLIENT-SIDE preview grid. Server previews die under E2E; the
* key-holding browser decrypts a ciphertext replay and renders read-only xterms. No inline script.
*
* The §4.4 replay crypto is imported from relay-e2e (P4) and injected into the grid — cited
* verbatim, never re-implemented.
*
* INTEGRATION POINT: `loadReplay` must fetch the host's ciphertext ring-buffer replay + the §4.5
* `hostContentSecret` (delivered via P5 after auth/step-up). Those endpoints are owned by P5/P1/P3;
* this glue wires the shape and throws until they land, so a card shows "unavailable" rather than a
* fabricated screen.
*/
import { deriveContentKey, openReplayCiphertext } from 'relay-e2e'
import { readConfig } from '../config'
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/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 {
const cfg = readConfig(window.location)
const root = document.getElementById('manage')
if (!root) return
const api = createApiClient(cfg)
mountPreviewGrid(root, api, loadReplay, { deriveContentKey, openReplayCiphertext })
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot)
} else {
boot()
}