restore.ts

7.4 KB · raw

1/**2 * restore.ts — Peach Codex restoration library (popup-side).3 *4 * Responsibilities:5 *  1. `parseRawScan` — defensively turn a raw scanned string into a6 *     `ChunkEnvelope`. Foreign QR/Aztec content, malformed JSON, and7 *     wrong-version envelopes are rejected without throwing.8 *  2. `ChunkBuffer` — accumulate per-chunk scans, enforce one-Codex-per-scan9 *     context, deduplicate, and verify per-chunk integrity hashes.10 *  3. `reconstructAndVerify` — once the buffer has enough chunks, hand them11 *     to Subagent 1's `reconstructPayloadFromChunks`, then re-derive the12 *     vault fingerprint and fail loudly on mismatch.13 *14 * This module never touches the camera, the SW, or storage. Camera capture15 * lives in `./scanner.ts`; the actual vault write happens behind the16 * `RESTORE_FROM_CODEX` message in the service worker.17 *18 * Why a class instead of bare functions: the buffer holds three pieces of19 * state that must stay in sync (chunks, context, accept history). A class20 * makes the invariants — first valid chunk establishes context, later21 * chunks must match — explicit at the call site.22 */2324import {25  CodexError,26  deriveShardCounts,27  type ChunkEnvelope,28  type CodexPayload,29} from "../../shared/codex-types";30import { reconstructPayloadFromChunks } from "./generate";31import {32  base64UrlToBytes,33  sha256Hex,34  verifyChunkIntegrity,35} from "./helpers";3637export interface ScannedChunk {38  envelope: ChunkEnvelope;39  bytes: Uint8Array;40}4142export type ChunkAddReason =43  | "duplicate"44  | "foreign_codex"45  | "bad_integrity"46  | "parse_error";4748export interface ChunkAddResult {49  accepted: boolean;50  reason?: ChunkAddReason;51}5253export interface ChunkBufferContext {54  verifyCode: string;55  total: number;56  dataShards: number;57}5859export class ChunkBuffer {60  private readonly chunks = new Map<number, ScannedChunk>();61  private ctx: ChunkBufferContext | null = null;6263  /**64   * Add an envelope. Returns `{ accepted: true }` on a fresh accept, or65   * `{ accepted: false, reason }` for any rejection. The buffer never throws —66   * the scanner UI needs to keep running across bad frames.67   *68   * Async because `verifyChunkIntegrity` is async (SubtleCrypto digest).69   * Callers in a scan loop should await but the UI need not block — a few70   * milliseconds per frame is well below 30fps decoding cadence.71   */72  async add(envelope: ChunkEnvelope): Promise<ChunkAddResult> {73    // Defensive: even though parseRawScan filters most of these, the buffer74    // is a public API and callers may hand-build envelopes (tests, future75    // import flows).76    if (envelope.v !== 2) {77      return { accepted: false, reason: "parse_error" };78    }7980    if (!(await verifyChunkIntegrity(envelope))) {81      return { accepted: false, reason: "bad_integrity" };82    }8384    if (this.ctx === null) {85      // First valid chunk establishes the Codex context. Later chunks must86      // agree on verifyCode and total — that is how we keep two overlapping87      // Codex prints from poisoning each other.88      //89      // parityShards is no longer fixed: derive it (and dataShards) from the90      // printed total via the inverse of `computeParityShards`. Malformed91      // totals throw — buffer.add returns parse_error.92      let dataShards: number;93      try {94        dataShards = deriveShardCounts(envelope.t).dataShards;95      } catch {96        return { accepted: false, reason: "parse_error" };97      }98      this.ctx = {99        verifyCode: envelope.c,100        total: envelope.t,101        dataShards,102      };103    } else if (envelope.c !== this.ctx.verifyCode) {104      return { accepted: false, reason: "foreign_codex" };105    }106107    if (this.chunks.has(envelope.i)) {108      return { accepted: false, reason: "duplicate" };109    }110111    this.chunks.set(envelope.i, {112      envelope,113      bytes: base64UrlToBytes(envelope.d),114    });115    return { accepted: true };116  }117118  size(): number {119    return this.chunks.size;120  }121122  getContext(): ChunkBufferContext | null {123    return this.ctx;124  }125126  isReady(): boolean {127    return this.ctx !== null && this.chunks.size >= this.ctx.dataShards;128  }129130  entries(): ScannedChunk[] {131    return Array.from(this.chunks.values());132  }133134  /** Index → raw chunk bytes, ready for `reconstructPayloadFromChunks`. */135  toChunkMap(): Map<number, Uint8Array> {136    const out = new Map<number, Uint8Array>();137    for (const [i, scanned] of this.chunks) {138      out.set(i, scanned.bytes);139    }140    return out;141  }142}143144export type ParseRawScanResult =145  | ChunkEnvelope146  | { error: "parse_error" | "wrong_version" };147148/**149 * Defensively turn a raw scanned string into a `ChunkEnvelope`. The scanner150 * fires on every successful decode — most decodes are noise (other QR codes,151 * stickers, old Codex prints) — so the caller must not assume any structure.152 */153export function parseRawScan(raw: string): ParseRawScanResult {154  let parsed: unknown;155  try {156    parsed = JSON.parse(raw);157  } catch {158    return { error: "parse_error" };159  }160161  if (typeof parsed !== "object" || parsed === null) {162    return { error: "parse_error" };163  }164165  const obj = parsed as Record<string, unknown>;166167  // Version check happens before shape validation so a v1 (or future-v3)168  // envelope can be reported distinctly from random JSON.169  if (obj.v !== 2) {170    // Any explicit `v` that isn't 2 is a wrong-version signal. Missing `v`171    // is plain noise → parse_error.172    if ("v" in obj) return { error: "wrong_version" };173    return { error: "parse_error" };174  }175176  const looksLikeEnvelope =177    typeof obj.c === "string" &&178    typeof obj.i === "number" &&179    typeof obj.t === "number" &&180    typeof obj.p === "boolean" &&181    typeof obj.d === "string" &&182    typeof obj.h === "string";183184  if (!looksLikeEnvelope) {185    return { error: "parse_error" };186  }187188  return obj as unknown as ChunkEnvelope;189}190191/**192 * Reconstruct the payload from a fully-loaded buffer and verify the vault193 * fingerprint round-trips. Throws `CodexError` on any inconsistency —194 * callers translate that into a visible "scan again" message.195 */196export async function reconstructAndVerify(197  buffer: ChunkBuffer,198): Promise<CodexPayload> {199  const ctx = buffer.getContext();200  if (ctx === null) {201    throw new CodexError(202      "INSUFFICIENT_CHUNKS",203      "No chunks have been scanned yet.",204    );205  }206207  const payload = await reconstructPayloadFromChunks(208    buffer.toChunkMap(),209    ctx.dataShards,210    ctx.total,211  );212213  // Re-derive the fingerprint exactly as the generator did. Mismatch here214  // means the reconstructed `vaultBlob` is corrupt even though every chunk215  // passed its hash — most likely a Reed-Solomon decode pulled the wrong216  // bytes into a parity-reconstructed slot. `sha256Hex` already returns217  // uppercase hex (see helpers.ts), so the `.toUpperCase()` is belt-and-218  // suspenders against a future helpers.ts change.219  //220  // AUD-004: legacy Codexes printed before v2.50.2 carry a 16-char (64-bit)221  // fingerprint; current Codexes carry 32 chars (128 bits). Slice by the222  // stored length so both verify without an envelope-version bump.223  const fingerprintLen = payload.vaultFingerprint.length;224  const fingerprint = (await sha256Hex(new TextEncoder().encode(payload.vaultBlob)))225    .slice(0, fingerprintLen)226    .toUpperCase();227  if (fingerprint !== payload.vaultFingerprint) {228    throw new CodexError(229      "RECONSTRUCTION_FINGERPRINT_MISMATCH",230      "Reconstructed vault fingerprint does not match the printed Codex. " +231        "Try scanning the Codex again from the beginning.",232    );233  }234235  return payload;236}