restore.ts

6.8 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  PARITY_SHARDS,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      this.ctx = {89        verifyCode: envelope.c,90        total: envelope.t,91        dataShards: envelope.t - PARITY_SHARDS,92      };93    } else if (envelope.c !== this.ctx.verifyCode) {94      return { accepted: false, reason: "foreign_codex" };95    }9697    if (this.chunks.has(envelope.i)) {98      return { accepted: false, reason: "duplicate" };99    }100101    this.chunks.set(envelope.i, {102      envelope,103      bytes: base64UrlToBytes(envelope.d),104    });105    return { accepted: true };106  }107108  size(): number {109    return this.chunks.size;110  }111112  getContext(): ChunkBufferContext | null {113    return this.ctx;114  }115116  isReady(): boolean {117    return this.ctx !== null && this.chunks.size >= this.ctx.dataShards;118  }119120  entries(): ScannedChunk[] {121    return Array.from(this.chunks.values());122  }123124  /** Index → raw chunk bytes, ready for `reconstructPayloadFromChunks`. */125  toChunkMap(): Map<number, Uint8Array> {126    const out = new Map<number, Uint8Array>();127    for (const [i, scanned] of this.chunks) {128      out.set(i, scanned.bytes);129    }130    return out;131  }132}133134export type ParseRawScanResult =135  | ChunkEnvelope136  | { error: "parse_error" | "wrong_version" };137138/**139 * Defensively turn a raw scanned string into a `ChunkEnvelope`. The scanner140 * fires on every successful decode — most decodes are noise (other QR codes,141 * stickers, old Codex prints) — so the caller must not assume any structure.142 */143export function parseRawScan(raw: string): ParseRawScanResult {144  let parsed: unknown;145  try {146    parsed = JSON.parse(raw);147  } catch {148    return { error: "parse_error" };149  }150151  if (typeof parsed !== "object" || parsed === null) {152    return { error: "parse_error" };153  }154155  const obj = parsed as Record<string, unknown>;156157  // Version check happens before shape validation so a v1 (or future-v3)158  // envelope can be reported distinctly from random JSON.159  if (obj.v !== 2) {160    // Any explicit `v` that isn't 2 is a wrong-version signal. Missing `v`161    // is plain noise → parse_error.162    if ("v" in obj) return { error: "wrong_version" };163    return { error: "parse_error" };164  }165166  const looksLikeEnvelope =167    typeof obj.c === "string" &&168    typeof obj.i === "number" &&169    typeof obj.t === "number" &&170    typeof obj.p === "boolean" &&171    typeof obj.d === "string" &&172    typeof obj.h === "string";173174  if (!looksLikeEnvelope) {175    return { error: "parse_error" };176  }177178  return obj as unknown as ChunkEnvelope;179}180181/**182 * Reconstruct the payload from a fully-loaded buffer and verify the vault183 * fingerprint round-trips. Throws `CodexError` on any inconsistency —184 * callers translate that into a visible "scan again" message.185 */186export async function reconstructAndVerify(187  buffer: ChunkBuffer,188): Promise<CodexPayload> {189  const ctx = buffer.getContext();190  if (ctx === null) {191    throw new CodexError(192      "INSUFFICIENT_CHUNKS",193      "No chunks have been scanned yet.",194    );195  }196197  const payload = await reconstructPayloadFromChunks(198    buffer.toChunkMap(),199    ctx.dataShards,200    ctx.total,201  );202203  // Re-derive the fingerprint exactly as the generator did. Mismatch here204  // means the reconstructed `vaultBlob` is corrupt even though every chunk205  // passed its hash — most likely a Reed-Solomon decode pulled the wrong206  // bytes into a parity-reconstructed slot. `sha256Hex` already returns207  // uppercase hex (see helpers.ts), so the `.toUpperCase()` is belt-and-208  // suspenders against a future helpers.ts change.209  const fingerprint = (await sha256Hex(new TextEncoder().encode(payload.vaultBlob)))210    .slice(0, 16)211    .toUpperCase();212  if (fingerprint !== payload.vaultFingerprint) {213    throw new CodexError(214      "RECONSTRUCTION_FINGERPRINT_MISMATCH",215      "Reconstructed vault fingerprint does not match the printed Codex. " +216        "Try scanning the Codex again from the beginning.",217    );218  }219220  return payload;221}