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.
51 lines
2.3 KiB
TypeScript
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()
|
|
}
|