1b75540dc84c

chore: copy audit-fix new files + clipboard.ts replacement; exclude internal drafts from tsc/vitest (pre-patch — HV-1 test red until HV-1)

time
2026-06-10 01:25 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
audit fix integration
parent
fecde68f0c43
11 files WASM-PROVENANCE.md+35 −0src/background/clipboard.ts+129 −4src/background/offscreen.html+11 −0src/background/offscreen.ts+43 −0src/background/pending-vouched-devices.ts+81 −0src/background/sync-alerts.ts+74 −0src/background/vault.sync-clamp.test.ts+139 −0src/shared/import-limits.ts+27 −0src/shared/sync-limits.ts+41 −0tsconfig.json+2 −1vitest.config.ts+1 −0
WASM-PROVENANCE.md+35 −0
@@ -0,0 +1,35 @@1+# WASM Blob Provenance2+3+> Target location: repository root (`WASM-PROVENANCE.md`). Added by audit4+> fix LV-9.5+6+The following packages ship prebuilt `.wasm` blobs that have **not** been7+byte-verified against a reproducible build:8+9+- `@bokuweb/[email protected]` — zstd compression (Codex / PeachScript payloads)10+- `@subspace/[email protected]` — Codex erasure coding11+- `[email protected]` — Argon2id KDF (**crypto-critical**)12+- vendored `public/ggwave/ggwave.js` + `ggwave.wasm` — ultrasonic pairing13+  (carries local patch PEACH-PATCH LV-7; see `src/drafts/AUDIT-FIXES/patches/LV-7-ggwave-newfunction-proto.md`)14+15+These are pinned to exact versions in `package.json` (audit fix LV-9). Any16+version bump requires, before merge:17+18+1. Manual review of the upstream diff/release notes.19+2. SHA-256 of the shipped `.wasm` blob recorded below, verified against the20+   upstream release artifact (or a local reproducible build where upstream21+   provides one).22+3. Re-application of any local patches (currently: ggwave LV-7).23+24+## Recorded digests25+26+Populate at integration time (`shasum -a 256 <blob>`), one line per blob:27+28+```29+<sha256>  node_modules/hash-wasm/dist/... (argon2id wasm)30+<sha256>  node_modules/@bokuweb/zstd-wasm/dist/... (zstd wasm)31+<sha256>  node_modules/@subspace/reed-solomon-erasure.wasm/... (rs wasm)32+<sha256>  public/ggwave/ggwave.wasm33+```34+35+Audit reference: peach-browser-security-audit.md, Open Question 3.

view file

src/background/clipboard.ts+129 −4
@@ -1,6 +1,32 @@1+// Clipboard write + reliable auto-clear — audit fix HV-2.2+//3+// The pre-fix implementation scheduled the clear with a bare `setTimeout`,4+// which does not survive MV3 service-worker eviction, and performed the5+// clear via `executeScript` into the *active tab*, which fails on6+// restricted pages (chrome://, web store) — both failure modes left the7+// secret on the OS clipboard indefinitely with the error swallowed.8+//9+// Now:10+//   - The clear is scheduled with chrome.alarms (survives worker restarts).11+//   - A best-effort setTimeout still runs as a fast path for sub-30s12+//     settings (chrome.alarms resolution floor is 0.5 min); the alarm is13+//     the guaranteed backstop.14+//   - The clear itself goes through an offscreen document15+//     (offscreen.html/offscreen.ts) — a stable extension-owned surface —16+//     with executeScript into the active tab as the Firefox fallback17+//     (Firefox MV2/MV3 has no chrome.offscreen).18+//   - A SHA-256 hash of the copied value (never the plaintext) is parked in19+//     chrome.storage.session so a restarted worker knows a clear is owed.20+121 import { getSettings } from "./vault";222 323 const DEFAULT_CLEAR_SECONDS = 30;24+export const CLIPBOARD_CLEAR_ALARM = "peach-clipboard-clear-current";25+const CLEAR_HASH_KEY = "clipboard-clear-hash";26+// chrome.alarms cannot fire sooner than 30s in stable Chrome. For shorter27+// user settings the setTimeout fast path handles the early clear and the28+// alarm remains as the eviction-proof backstop.29+const MIN_ALARM_MINUTES = 0.5;430 531 let clipboardClearTimer: ReturnType<typeof setTimeout> | null = null;632 @@ -24,6 +50,16 @@2450   await navigator.clipboard.writeText(text);2551 }2652 53+async function hashForVerification(value: string): Promise<string> {54+  const digest = await crypto.subtle.digest(55+    "SHA-256",56+    new TextEncoder().encode(value),57+  );58+  return Array.from(new Uint8Array(digest))59+    .map((b) => b.toString(16).padStart(2, "0"))60+    .join("");61+}62+2763 export async function copyToClipboard(2864   text: string,2965   _label: string,@@ -33,10 +69,14 @@3369     clearTimeout(clipboardClearTimer);3470     clipboardClearTimer = null;3571   }72+  // A second copy supersedes the first: cancel the previous alarm so the73+  // old timer can't clear the new value early.74+  await chrome.alarms.clear(CLIPBOARD_CLEAR_ALARM).catch(() => false);75+3676   // The popup/content caller may have already written the clipboard from a3777   // focused document with a fresh user gesture — that path is more reliable3878   // than the SW's `executeScript` into the active tab. When that happens, we39-  // only need to schedule the auto-clear timer here.79+  // only need to schedule the auto-clear here.4080   if (!alreadyWritten) {4181     await writeToClipboard(text);4282   }@@ -47,10 +87,95 @@4787       ? settingsSeconds4888       : DEFAULT_CLEAR_SECONDS) * 1000;4989 90+  // Park a hash (never plaintext) so a cold-started worker knows a clear is91+  // owed. storage.session is memory-backed and cleared on browser exit.92+  try {93+    const hash = await hashForVerification(text);94+    await chrome.storage.session.set({ [CLEAR_HASH_KEY]: hash });95+  } catch {96+    // Hash bookkeeping is best-effort; the alarm still fires without it.97+  }98+99+  // Eviction-proof backstop (HV-2a). delayInMinutes floors at 0.5 in stable100+  // Chrome; for shorter settings the setTimeout below clears earlier and the101+  // alarm's clear of an already-empty clipboard is harmless.102+  await chrome.alarms.create(CLIPBOARD_CLEAR_ALARM, {103+    delayInMinutes: Math.max(clearAfterMs / 60_000, MIN_ALARM_MINUTES),104+  });105+106+  // Fast path while the worker stays alive — honors sub-30s settings.50107   clipboardClearTimer = setTimeout(async () => {51-    try {52-      await writeToClipboard("");53-    } catch {}54108     clipboardClearTimer = null;109+    await clearClipboardNow();55110   }, clearAfterMs);111+}112+113+/**114+ * Clear the clipboard through the most reliable available surface and tear115+ * down the bookkeeping. Called by the setTimeout fast path and by the116+ * onAlarm backstop (background entry point).117+ */118+export async function clearClipboardNow(): Promise<void> {119+  let cleared = false;120+121+  // Chrome: offscreen document — works with no tab and on restricted pages.122+  if (chrome.offscreen?.createDocument) {123+    try {124+      await chrome.offscreen.createDocument({125+        url: "offscreen.html",126+        reasons: [chrome.offscreen.Reason.CLIPBOARD],127+        justification: "Clear sensitive clipboard content after timeout",128+      });129+    } catch {130+      // Already exists — fine, message it directly.131+    }132+    try {133+      const res = (await chrome.runtime.sendMessage({134+        type: "CLEAR_CLIPBOARD",135+      })) as { ok?: boolean } | undefined;136+      cleared = res?.ok === true;137+    } catch {138+      cleared = false;139+    }140+    // Close the offscreen document so it doesn't linger; ignore failures.141+    try {142+      await chrome.offscreen.closeDocument();143+    } catch {144+      /* not open / race — fine */145+    }146+  }147+148+  // Firefox (no chrome.offscreen) or offscreen failure: active-tab path.149+  if (!cleared) {150+    try {151+      await writeToClipboard("");152+      cleared = true;153+    } catch {154+      cleared = false;155+    }156+  }157+158+  if (cleared) {159+    await chrome.storage.session.remove(CLEAR_HASH_KEY).catch(() => undefined);160+    await chrome.alarms.clear(CLIPBOARD_CLEAR_ALARM).catch(() => false);161+  } else {162+    // Could not clear (e.g. only a chrome:// tab focused on Firefox).163+    // Re-arm a short retry alarm rather than swallowing the failure —164+    // the secret must not silently persist.165+    await chrome.alarms.create(CLIPBOARD_CLEAR_ALARM, {166+      delayInMinutes: MIN_ALARM_MINUTES,167+    });168+  }169+}170+171+/**172+ * Wire the eviction-proof clear. Must be called during the worker's initial173+ * synchronous evaluation (same rule as the onMessage listener) — from174+ * entry.ts start().175+ */176+export function setupClipboardClearAlarm(): void {177+  chrome.alarms.onAlarm.addListener((alarm) => {178+    if (alarm.name !== CLIPBOARD_CLEAR_ALARM) return;179+    void clearClipboardNow();180+  });56181 }

view file

src/background/offscreen.html+11 −0
@@ -0,0 +1,11 @@1+<!doctype html>2+<!-- Offscreen document for reliable clipboard clearing (audit fix HV-2c).3+     Created on demand by the background onAlarm handler; closed after use. -->4+<html>5+  <head>6+    <meta charset="utf-8" />7+  </head>8+  <body>9+    <script type="module" src="./offscreen.ts"></script>10+  </body>11+</html>

view file

src/background/offscreen.ts+43 −0
@@ -0,0 +1,43 @@1+// Offscreen document script — audit fix HV-2c.2+//3+// The MV3 service worker cannot reliably write the clipboard itself (no DOM4+// focus), and `chrome.scripting.executeScript` into the *active tab* fails on5+// restricted pages (chrome://, the web store) or when no tab exists — which is6+// exactly when a stale password could be left on the clipboard forever. An7+// offscreen document is a stable, extension-owned surface that can always8+// service the clear.9+//10+// Loaded on demand by the background's onAlarm handler (clipboard.ts) via11+// chrome.offscreen.createDocument({ reasons: ["CLIPBOARD"] }).12+13+chrome.runtime.onMessage.addListener(14+  (msg: unknown, _sender, sendResponse: (r: unknown) => void) => {15+    const type = (msg as { type?: string } | null)?.type;16+    if (type !== "CLEAR_CLIPBOARD") return undefined;17+    // navigator.clipboard.writeText("") clears the platform clipboard.18+    // The offscreen document is extension-origin, so no user gesture is19+    // required under the CLIPBOARD offscreen reason.20+    navigator.clipboard21+      .writeText("")22+      .then(() => sendResponse({ ok: true }))23+      .catch((err: unknown) => {24+        // Fallback for engines where writeText("") is rejected: textarea +25+        // execCommand. Deprecated but still functional in offscreen docs.26+        try {27+          const ta = document.createElement("textarea");28+          ta.value = " ";29+          document.body.appendChild(ta);30+          ta.select();31+          document.execCommand("copy");32+          ta.remove();33+          sendResponse({ ok: true, via: "execCommand" });34+        } catch {35+          sendResponse({36+            ok: false,37+            error: err instanceof Error ? err.message : "clipboard_clear_failed",38+          });39+        }40+      });41+    return true; // async sendResponse42+  },43+);

view file

src/background/pending-vouched-devices.ts+81 −0
@@ -0,0 +1,81 @@1+// Pending vouched devices — audit fix MV-7.2+//3+// A verified extension-signed device manifest may introduce a device this4+// install has never seen. Previously such devices were upserted straight5+// into the paired-devices registry (trustSource:"vouched") with no user6+// confirmation — making the extension a single point of total compromise7+// for the device graph. Now new vouched devices land HERE, sync-inert,8+// until the user confirms them in the popup (CONFIRM_VOUCHED_DEVICE) or9+// rejects them (REJECT_VOUCHED_DEVICE).10+//11+// Storage: chrome.storage.local, same locality rules as paired-devices.ts —12+// these records contain public keys and must not roam through a third-party13+// sync back-end.14+15+export interface PendingVouchedDevice {16+  deviceId: string;17+  deviceName: string;18+  deviceType: string;19+  longTermPublicKeyHex: string;20+  signingPublicKeyHex: string;21+  wsPort?: number;22+  /** pairedAt claimed by the vouching manifest — carried through on confirm. */23+  pairedAt: number;24+  receivedAt: number;25+  /** deviceId of the extension whose signed manifest vouched for this device. */26+  vouchedByDeviceId: string;27+}28+29+const PENDING_KEY = "peach_pending_vouched_devices_v1";30+const MAX_PENDING = 50;31+32+export async function listPendingVouchedDevices(): Promise<PendingVouchedDevice[]> {33+  try {34+    const result = await chrome.storage.local.get(PENDING_KEY);35+    const value = (result as Record<string, unknown>)[PENDING_KEY];36+    if (!Array.isArray(value)) return [];37+    return value.filter(isPendingVouchedDevice);38+  } catch {39+    return [];40+  }41+}42+43+/** Add or refresh a pending device (keyed by deviceId). Bounded queue. */44+export async function addPendingVouchedDevice(45+  device: PendingVouchedDevice,46+): Promise<void> {47+  const pending = await listPendingVouchedDevices();48+  const next = pending.filter((d) => d.deviceId !== device.deviceId);49+  next.push(device);50+  await chrome.storage.local.set({ [PENDING_KEY]: next.slice(-MAX_PENDING) });51+}52+53+export async function getPendingVouchedDevice(54+  deviceId: string,55+): Promise<PendingVouchedDevice | null> {56+  const pending = await listPendingVouchedDevices();57+  return pending.find((d) => d.deviceId === deviceId) ?? null;58+}59+60+export async function removePendingVouchedDevice(deviceId: string): Promise<void> {61+  const pending = await listPendingVouchedDevices();62+  await chrome.storage.local.set({63+    [PENDING_KEY]: pending.filter((d) => d.deviceId !== deviceId),64+  });65+}66+67+function isPendingVouchedDevice(x: unknown): x is PendingVouchedDevice {68+  if (typeof x !== "object" || x === null) return false;69+  const r = x as Record<string, unknown>;70+  return (71+    typeof r.deviceId === "string" && !!r.deviceId &&72+    typeof r.deviceName === "string" &&73+    typeof r.deviceType === "string" &&74+    typeof r.longTermPublicKeyHex === "string" &&75+    /^[0-9a-f]{64}$/.test(r.longTermPublicKeyHex as string) &&76+    typeof r.signingPublicKeyHex === "string" &&77+    /^[0-9a-f]{64}$/.test(r.signingPublicKeyHex as string) &&78+    typeof r.receivedAt === "number" &&79+    typeof r.vouchedByDeviceId === "string"80+  );81+}

view file

src/background/sync-alerts.ts+74 −0
@@ -0,0 +1,74 @@1+// Pending sync alerts — audit fix HV-1b.2+//3+// When a sync round accepts a remote entry that REPLACES an existing local4+// password, or soft-deletes a local entry via a remote tombstone, the user5+// gets no signal today. This queue records those remote mutations so the6+// popup can display them on next open (non-blocking — the sync itself is7+// not interrupted).8+//9+// Persistence: chrome.storage.local under a dedicated key so alerts survive10+// MV3 worker eviction between the sync round and the next popup open. The11+// queue is cleared after the popup acknowledges display (GET_SYNC_ALERTS12+// with `consume: true`).13+14+export interface SyncAlert {15+  type: "REMOTE_PASSWORD_CHANGE" | "REMOTE_DELETE";16+  /** VaultEntry.id — a UUID string in this codebase (spec deviation: the17+   *  fix spec typed this `number`, but entry ids are `crypto.randomUUID()`). */18+  entryId: string;19+  entryTitle: string;20+  /** Peer device name when known; "paired device" when the merge layer21+   *  had no device context. */22+  deviceName: string;23+  timestamp: number;24+}25+26+const SYNC_ALERTS_KEY = "peach_pending_sync_alerts_v1";27+/** Bound the queue so a hostile peer cannot grow storage without limit. */28+const MAX_PENDING_ALERTS = 200;29+30+export async function recordSyncAlerts(alerts: SyncAlert[]): Promise<void> {31+  if (alerts.length === 0) return;32+  const existing = await readAlerts();33+  const merged = [...existing, ...alerts].slice(-MAX_PENDING_ALERTS);34+  try {35+    await chrome.storage.local.set({ [SYNC_ALERTS_KEY]: merged });36+  } catch {37+    // Alert persistence must never block or fail a sync merge.38+  }39+}40+41+export async function getPendingSyncAlerts(): Promise<SyncAlert[]> {42+  return readAlerts();43+}44+45+export async function clearPendingSyncAlerts(): Promise<void> {46+  try {47+    await chrome.storage.local.remove(SYNC_ALERTS_KEY);48+  } catch {49+    /* non-fatal */50+  }51+}52+53+async function readAlerts(): Promise<SyncAlert[]> {54+  try {55+    const result = await chrome.storage.local.get(SYNC_ALERTS_KEY);56+    const value = (result as Record<string, unknown>)[SYNC_ALERTS_KEY];57+    if (!Array.isArray(value)) return [];58+    return value.filter(isSyncAlert);59+  } catch {60+    return [];61+  }62+}63+64+function isSyncAlert(x: unknown): x is SyncAlert {65+  if (typeof x !== "object" || x === null) return false;66+  const r = x as Record<string, unknown>;67+  return (68+    (r.type === "REMOTE_PASSWORD_CHANGE" || r.type === "REMOTE_DELETE") &&69+    typeof r.entryId === "string" &&70+    typeof r.entryTitle === "string" &&71+    typeof r.deviceName === "string" &&72+    typeof r.timestamp === "number"73+  );74+}

view file

src/background/vault.sync-clamp.test.ts+139 −0
@@ -0,0 +1,139 @@1+// Draft unit tests for HV-1 — sync timestamp clamp + remote-mutation alerts.2+// Target location after integration: src/background/vault.sync-clamp.test.ts3+// (or merged into the existing vault sync tests). Uses the same chrome4+// storage mock harness as src/background/vault.test.ts.5+6+import { beforeEach, describe, expect, it } from "vitest";7+import {8+  MAX_CLOCK_SKEW_MS,9+  clampSyncTimestamp,10+} from "../shared/sync-limits";11+import {12+  applyMergedRemoteEntries,13+  addEntry,14+  createVault,15+  getEntries,16+  getEntryById,17+} from "./vault";18+import { getPendingSyncAlerts, clearPendingSyncAlerts } from "./sync-alerts";19+20+describe("clampSyncTimestamp", () => {21+  it("clamps a far-future timestamp to now + MAX_CLOCK_SKEW_MS", () => {22+    const now = 1_750_000_000_000;23+    const farFuture = now + 10_000_000;24+    expect(clampSyncTimestamp(farFuture, now)).toBe(now + MAX_CLOCK_SKEW_MS);25+  });26+27+  it("passes through timestamps within the skew window", () => {28+    const now = 1_750_000_000_000;29+    expect(clampSyncTimestamp(now - 1000, now)).toBe(now - 1000);30+    expect(clampSyncTimestamp(now + MAX_CLOCK_SKEW_MS - 1, now)).toBe(31+      now + MAX_CLOCK_SKEW_MS - 1,32+    );33+  });34+});35+36+describe("applyMergedRemoteEntries — HV-1", () => {37+  beforeEach(async () => {38+    // chrome.storage mock reset per the existing vault.test.ts harness.39+    await createVault("correct horse battery staple");40+    await clearPendingSyncAlerts();41+  });42+43+  it("clamps a far-future remote modifiedAt so a local edit can win", async () => {44+    const local = await addEntry({45+      title: "Bank",46+      username: "june",47+      password: "strong-local-password",48+      url: "https://bank.example.com",49+      type: "login",50+    });51+52+    const farFuture = Date.now() + 10_000_000;53+    await applyMergedRemoteEntries(54+      [{ ...local, password: "attacker-password", modifiedAt: farFuture }],55+      {},56+    );57+58+    const merged = getEntryById(local.id)!;59+    // The remote value wins NOW (it is newer than the local edit), but its60+    // timestamp is clamped — it cannot pin the far future.61+    expect(merged.modifiedAt).toBeLessThanOrEqual(Date.now() + MAX_CLOCK_SKEW_MS);62+63+    // A subsequent local edit (monotonic clock) beats the clamped value.64+    const { updateEntry } = await import("./vault");65+    await updateEntry(local.id, { password: "honest-newer-password" });66+    // Re-sync the stale attacker value — it must NOT win again.67+    await applyMergedRemoteEntries(68+      [{ ...merged, password: "attacker-password", modifiedAt: farFuture }],69+      {},70+    );71+    expect(getEntryById(local.id)!.password).toBe("honest-newer-password");72+  });73+74+  it("clamps a far-future tombstone ts before applying", async () => {75+    const local = await addEntry({76+      title: "Forum",77+      username: "june",78+      password: "pw",79+      url: "https://forum.example.com",80+      type: "login",81+    });82+    const farFuture = Date.now() + 10_000_000;83+    await applyMergedRemoteEntries([], { [local.id]: farFuture });84+85+    const deleted = getEntries().length === 086+      ? undefined87+      : undefined; // entry is soft-deleted — read via getDeletedEntries88+    void deleted;89+    const { getDeletedEntries } = await import("./vault");90+    const tombstoned = getDeletedEntries().find((e) => e.id === local.id)!;91+    expect(tombstoned.deletedAt).toBeLessThanOrEqual(92+      Date.now() + MAX_CLOCK_SKEW_MS,93+    );94+  });95+96+  it("records a REMOTE_PASSWORD_CHANGE alert when a remote replaces a local password", async () => {97+    const local = await addEntry({98+      title: "Email",99+      username: "june",100+      password: "old-password",101+      url: "https://mail.example.com",102+      type: "login",103+    });104+    await applyMergedRemoteEntries(105+      [{ ...local, password: "replaced-by-peer", modifiedAt: Date.now() + 1000 }],106+      {},107+      { deviceName: "Pixel 9" },108+    );109+    const alerts = await getPendingSyncAlerts();110+    expect(alerts).toHaveLength(1);111+    expect(alerts[0]).toMatchObject({112+      type: "REMOTE_PASSWORD_CHANGE",113+      entryId: local.id,114+      entryTitle: "Email",115+      deviceName: "Pixel 9",116+    });117+  });118+119+  it("records a REMOTE_DELETE alert when a remote tombstone deletes a live entry", async () => {120+    const local = await addEntry({121+      title: "Shop",122+      username: "june",123+      password: "pw",124+      url: "https://shop.example.com",125+      type: "login",126+    });127+    await applyMergedRemoteEntries([], { [local.id]: Date.now() + 1000 }, {128+      deviceName: "Pixel 9",129+    });130+    const alerts = await getPendingSyncAlerts();131+    expect(alerts).toHaveLength(1);132+    expect(alerts[0]).toMatchObject({133+      type: "REMOTE_DELETE",134+      entryId: local.id,135+      entryTitle: "Shop",136+      deviceName: "Pixel 9",137+    });138+  });139+});

view file

src/shared/import-limits.ts+27 −0
@@ -0,0 +1,27 @@1+// Import-path resource bounds — audit fixes MV-4 (decompression bomb) and2+// MV-5 (unbounded file/string allocation).3+//4+// Every untrusted import surface (pasted PeachScript/backup strings, .peach /5+// .json files, Codex PDFs) must check these BEFORE allocating or inflating.6+7+export const IMPORT_LIMITS = {8+  /** Hard ceiling on any decompressed output (zstd sections, pako inflate). */9+  MAX_DECOMPRESSED_BYTES: 64 * 1024 * 1024, // 64 MiB10+  /** Ceiling on an attacker-declared pre-decompress length field. */11+  MAX_DECLARED_LENGTH: 64 * 1024 * 1024, // 64 MiB12+  /** Max PDF file size accepted by the Codex importer. */13+  MAX_PDF_BYTES: 25 * 1024 * 1024, // 25 MiB14+  /** Max raw JSON/CSV/PeachScript string length accepted by import/restore. */15+  MAX_JSON_BYTES: 10 * 1024 * 1024, // 10 MiB16+} as const;17+18+/**19+ * Error code shared by all import-path size rejections. The import UI maps20+ * it to a user-readable message ("This file is too large to be a Peach21+ * backup …") instead of surfacing a raw thrown error.22+ */23+export const IMPORT_TOO_LARGE = "IMPORT_TOO_LARGE";24+25+export function importTooLarge(detail: string): Error {26+  return new Error(`${IMPORT_TOO_LARGE}: ${detail}`);27+}

view file

src/shared/sync-limits.ts+41 −0
@@ -0,0 +1,41 @@1+// Sync trust + resource bounds — audit fixes HV-1 (timestamp clamp) and2+// MV-3 (receive-path size bounds). Shared between the background sync3+// stack (vault.ts, chirp-sync-vault-adapter.ts, chirp-sync-coordinator.ts)4+// and the transport (shared/chirp-sync-transport.ts), so it lives in shared/.5+6+/**7+ * HV-1: maximum tolerated forward clock skew on any timestamp received from8+ * a sync peer. Matches the SYNC_HELLO skew warning window's intent — a peer9+ * whose clock is slightly ahead still merges cleanly, but a deliberately10+ * far-future `modifiedAt`/`deletedAt` can no longer permanently pin a11+ * malicious value against honest local edits.12+ */13+export const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000;14+15+/**16+ * Clamp a peer-supplied timestamp to `now + MAX_CLOCK_SKEW_MS`. Applied to17+ * every remote `modifiedAt` and `deletedAt` BEFORE it is compared against18+ * any local timestamp (HV-1). A clamped far-future timestamp loses to any19+ * local edit made more than MAX_CLOCK_SKEW_MS from now, and the monotonic20+ * local entry clock (vault.ts monotonicEntryTimestamp) can always advance21+ * past it.22+ */23+export function clampSyncTimestamp(ts: number, now: number = Date.now()): number {24+  return Math.min(ts, now + MAX_CLOCK_SKEW_MS);25+}26+27+/**28+ * MV-3: hard resource bounds on the sync receive path. A paired/vouched29+ * peer (or anything that passes the Noise XX static-key pin) must not be30+ * able to OOM the service worker with one frame or one giant manifest.31+ */32+export const SYNC_LIMITS = {33+  /** Max bytes per decrypted Noise frame, checked before JSON.parse. */34+  MAX_FRAME_BYTES: 50 * 1024 * 1024, // 50 MiB35+  /** Max entries accepted in one SYNC_ENTRIES / manifest payload. */36+  MAX_ENTRY_COUNT: 100_000,37+  /** Max plaintext size of one decrypted entry envelope. */38+  MAX_ENTRY_PLAINTEXT_BYTES: 512 * 1024, // 512 KiB39+  /** Max tombstones accepted in one SYNC_TOMBSTONES payload. */40+  MAX_TOMBSTONE_COUNT: 100_000,41+} as const;

view file

tsconfig.json+2 −1
@@ -16,5 +16,6 @@1616     "resolveJsonModule": true,1717     "types": ["vite/client", "chrome", "node", "playwright"]1818   },19-  "include": ["src", "tests"]19+  "include": ["src", "tests"],20+  "exclude": ["src/drafts"]2021 }

view file

vitest.config.ts+1 −0
@@ -5,6 +5,7 @@55   plugins: [solidPlugin()],66   test: {77     include: ["src/**/*.test.ts", "src/**/*.test.tsx"],8+    exclude: ["src/drafts/**"],89     environment: "node",910     // M14/SEC-007: tests exercising dev-only handlers1011     // (DEBUG_INJECT_INCOMING_PAIRING) use vi.stubEnv('MODE','development').

view file