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
↓ peach-browser-1b75540d.tar.gzatlas at this save
files changed11 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 −0WASM-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.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 }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>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+);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+}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+}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+});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+}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;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 }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').