927a4bd80ebc

add PCH-02-010 fix draft: clipboard clear hash-match — Option A doc-only (recommended, no clipboardRead grant) vs Option B full offscreen SHA-256 match implementation with manifest permission gate + 4 tests

time
2026-06-12 20:16 UTC · 4 months ago
machine
koh2
agent
claude-fable-5
session
pch-02 fix drafts
parent
35841e9da617
2 files src/drafts/PCH-02-FIXES/PCH-02-010-clipboard-hash-match.patch.md+253 −0src/drafts/PCH-02-FIXES/clipboard.hash-match.test.ts+148 −0
src/drafts/PCH-02-FIXES/PCH-02-010-clipboard-hash-match.patch.md+253 −0
@@ -0,0 +1,253 @@1+# PCH-02-010 — Clipboard auto-clear verifies the parked hash before clearing2+3+**Severity:** Informational4+**Target files:** `src/background/clipboard.ts:124-178` (`clearClipboardNow`),5+`src/background/offscreen.ts`, `manifest.json` (⚠ permission), comment in6+`clipboard.ts:18-19`7+**Report:** peach-code-review.md, Finding PCH-02-0108+9+## ⚠ Integration gate: this fix requires the `clipboardRead` permission10+11+Verified constraints of the only available read surfaces:12+13+- `navigator.clipboard.readText()` in an offscreen document rejects with14+  *"Document is not focused"* — offscreen documents are never focused. Not15+  reliable.16+- The workable offscreen read path is `document.execCommand("paste")` into a17+  textarea (the documented offscreen-clipboard technique), which **requires18+  adding `"clipboardRead"` to `manifest.json` permissions** (the manifest19+  currently grants `clipboardWrite` only).20+21+`clipboardRead` surfaces in the Chrome Web Store as **"Read data you copy and22+paste"** — a visible permission expansion for a password manager, and a23+store re-review trigger. The current unconditional clear is conservative and24+costs only a minor UX surprise (the review's own framing). **Decision for the25+integrator:**26+27+- **Option A (no new permission):** land only the comment fix below — the28+  parked hash is *not* dead bookkeeping (PCH-01-021 made it the clear-owed29+  flag); state explicitly that match-before-clear was evaluated and rejected30+  because the required `clipboardRead` grant is a worse privacy posture than31+  the UX cost it saves. This satisfies the brief's fallback ("document this32+  and clear unconditionally … acceptable behavior").33+- **Option B (full fix, below):** hash-match before clearing, with the34+  permission addition flagged in the release notes. The hash comparison is35+  designed so plaintext never leaves the offscreen document.36+37+Recommendation: **Option A** for v4.0.1; hold Option B in this draft until a38+release where the permission expansion is worth bundling. Both are complete39+below — whichever lands, the parked-hash comment stops reading as dead code.40+41+---42+43+## Option A — comment-only edit (clipboard.ts:18-19)44+45+```typescript46+// BEFORE (clipboard.ts:18-19)47+//   - A SHA-256 hash of the copied value (never the plaintext) is parked in48+//     chrome.storage.session so a restarted worker knows a clear is owed.49+50+// AFTER51+//   - A SHA-256 hash of the copied value (never the plaintext) is parked in52+//     chrome.storage.session so a restarted worker knows a clear is owed53+//     (PCH-01-021: the alarm handler skips the offscreen churn when no clear54+//     is owed — the hash IS load-bearing, as the owed-flag, not dead state).55+//   - PCH-02-010: the clear is deliberately UNCONDITIONAL — we do not read56+//     the clipboard to check it still holds our value, because every viable57+//     read surface (execCommand("paste") in the offscreen document) requires58+//     the `clipboardRead` permission ("Read data you copy and paste" in the59+//     store listing) — a worse privacy posture than occasionally wiping a60+//     value the user copied later. Secret-must-not-persist wins the tie. A61+//     ready hash-match implementation is parked in62+//     src/drafts/PCH-02-FIXES/PCH-02-010-clipboard-hash-match.patch.md.63+```64+65+Option A save message:66+`docs: PCH-02-010 document unconditional clipboard clear as deliberate — hash-match requires clipboardRead permission, implementation parked`67+68+---69+70+## Option B — full hash-match implementation71+72+### B1. manifest.json — permission73+74+```json75+// "permissions" array: ADD76+    "clipboardRead",77+```78+79+(`manifest.firefox.json` is NOT changed — the Firefox path has no offscreen80+surface and keeps the unconditional clear; see B3.)81+82+### B2. offscreen.ts — hash the live clipboard locally, return only the hash83+84+Append a second listener (same file, after the CLEAR_CLIPBOARD listener).85+The plaintext never crosses the message channel — the offscreen document86+hashes in place and replies with hex SHA-256 only:87+88+```typescript89+// PCH-02-010: report a SHA-256 of the current clipboard text so the90+// background can decide whether the parked auto-clear still applies.91+// Hashing happens HERE so the clipboard plaintext (which may be something92+// unrelated and sensitive the user copied later) never crosses the93+// extension message channel.94+chrome.runtime.onMessage.addListener(95+  (msg: unknown, _sender, sendResponse: (r: unknown) => void) => {96+    const type = (msg as { type?: string } | null)?.type;97+    if (type !== "GET_CLIPBOARD_HASH") return undefined;98+    void (async () => {99+      try {100+        let text: string;101+        try {102+          text = await navigator.clipboard.readText();103+        } catch {104+          // Offscreen docs are never focused, so readText usually rejects;105+          // execCommand("paste") into a textarea is the documented offscreen106+          // read path (requires the clipboardRead permission).107+          const ta = document.createElement("textarea");108+          document.body.appendChild(ta);109+          ta.focus();110+          const ok = document.execCommand("paste");111+          text = ta.value;112+          ta.remove();113+          if (!ok) throw new Error("paste_failed");114+        }115+        const digest = await crypto.subtle.digest(116+          "SHA-256",117+          new TextEncoder().encode(text),118+        );119+        const hash = Array.from(new Uint8Array(digest))120+          .map((b) => b.toString(16).padStart(2, "0"))121+          .join("");122+        sendResponse({ ok: true, hash });123+      } catch {124+        sendResponse({ ok: false });125+      }126+    })();127+    return true; // async sendResponse128+  },129+);130+```131+132+### B3. clipboard.ts — compare before clearing in `clearClipboardNow`133+134+Replace the Chrome/offscreen branch (clipboard.ts:124-155). The offscreen135+document is created once and reused for both the hash check and the clear:136+137+```typescript138+export async function clearClipboardNow(): Promise<void> {139+  let cleared = false;140+  let skipClear = false;141+142+  // Chrome: offscreen document — works with no tab and on restricted pages.143+  if (chrome.offscreen?.createDocument) {144+    try {145+      await chrome.offscreen.createDocument({146+        // crxjs emits the offscreen page preserving its source path under the147+        // Chrome dist root (see vite.config.ts offscreen input). Keep this URL148+        // aligned with that emitted location.149+        url: "src/background/offscreen.html",150+        reasons: [chrome.offscreen.Reason.CLIPBOARD],151+        justification: "Clear sensitive clipboard content after timeout",152+      });153+    } catch {154+      // Already exists — fine, message it directly.155+    }156+157+    // PCH-02-010: the parked SHA-256 is now also a MATCH check, not only the158+    // clear-owed flag (PCH-01-021). If the clipboard verifiably holds a159+    // DIFFERENT value, the user already replaced our copy — wiping would160+    // destroy their data for no confidentiality gain. Unreadable clipboard161+    // (no parked hash / read failure / no response) falls through to the162+    // clear: secret-must-not-persist wins every ambiguous case.163+    try {164+      const stored = await chrome.storage.session.get(CLEAR_HASH_KEY);165+      const parked = stored?.[CLEAR_HASH_KEY];166+      if (typeof parked === "string") {167+        const res = (await chrome.runtime.sendMessage({168+          type: "GET_CLIPBOARD_HASH",169+        })) as { ok?: boolean; hash?: string } | undefined;170+        if (res?.ok === true && typeof res.hash === "string" && res.hash !== parked) {171+          skipClear = true;172+        }173+      }174+    } catch {175+      // Comparison is best-effort — clear anyway.176+    }177+178+    if (!skipClear) {179+      try {180+        const res = (await chrome.runtime.sendMessage({181+          type: "CLEAR_CLIPBOARD",182+        })) as { ok?: boolean } | undefined;183+        cleared = res?.ok === true;184+      } catch {185+        cleared = false;186+      }187+    }188+    // Close the offscreen document so it doesn't linger; ignore failures.189+    try {190+      await chrome.offscreen.closeDocument();191+    } catch {192+      /* not open / race — fine */193+    }194+  }195+196+  if (skipClear) {197+    // The clipboard no longer holds our value — the clear is no longer owed.198+    await chrome.storage.session.remove(CLEAR_HASH_KEY).catch(() => undefined);199+    await chrome.alarms.clear(CLIPBOARD_CLEAR_ALARM).catch(() => false);200+    return;201+  }202+203+  // Firefox (no chrome.offscreen) or offscreen failure: active-tab path.204+  // No read surface here — the clear stays unconditional (pre-fix behaviour).205+  if (!cleared) {206+    try {207+      await writeToClipboard("");208+      cleared = true;209+    } catch {210+      cleared = false;211+    }212+  }213+214+  if (cleared) {215+    await chrome.storage.session.remove(CLEAR_HASH_KEY).catch(() => undefined);216+    await chrome.alarms.clear(CLIPBOARD_CLEAR_ALARM).catch(() => false);217+  } else {218+    // Could not clear (e.g. only a chrome:// tab focused on Firefox).219+    // Re-arm a short retry alarm rather than swallowing the failure —220+    // the secret must not silently persist.221+    await chrome.alarms.create(CLIPBOARD_CLEAR_ALARM, {222+      delayInMinutes: MIN_ALARM_MINUTES,223+    });224+  }225+}226+```227+228+Behavioural notes (all verified against the current file):229+- The fast-path `setTimeout` and the alarm backstop both call230+  `clearClipboardNow`, so both honour the match check — no second wiring231+  point.232+- If the parked hash is missing (bookkeeping write failed on copy), the check233+  is skipped and the clear proceeds — identical to today.234+- Deviation from the brief's sketch: the brief hashes with BLAKE3; the parked235+  hash is and stays SHA-256 (`hashForVerification`, clipboard.ts:61-69) — the236+  comparison must use the hash that is actually parked.237+- Deviation from the brief's sketch: the brief returns `true` ("clear") when238+  the parked hash is absent; the existing PCH-01-021 alarm handler already239+  treats absent-hash as "nothing owed" and skips. This patch preserves that:240+  absence short-circuits in the ALARM handler (unchanged), and in241+  `clearClipboardNow` absence just means "no comparison possible → clear",242+  which only the fast-path timer reaches.243+244+### B4. Tests (Option B only)245+246+`clipboard.hash-match.test.ts` (in this folder) — 4 tests: matching hash →247+CLEAR_CLIPBOARD sent + bookkeeping torn down; mismatching hash → clear248+skipped + bookkeeping torn down; hash-read failure → cleared anyway249+(conservative); Firefox path (no chrome.offscreen) → unconditional clear250+unchanged. Destination: `src/background/clipboard.hash-match.test.ts`.251+252+Option B save message:253+`fix: PCH-02-010 clipboard auto-clear verifies parked-hash match via offscreen SHA-256 before clearing — adds clipboardRead permission (Chrome only)`

view file

src/drafts/PCH-02-FIXES/clipboard.hash-match.test.ts+148 −0
@@ -0,0 +1,148 @@1+// PCH-02-010 regression tests (Option B only) — the auto-clear must compare2+// the live clipboard hash (computed inside the offscreen document) against3+// the parked SHA-256 before wiping, and must stay conservative when the4+// comparison is unavailable.5+//6+// DESTINATION: src/background/clipboard.hash-match.test.ts7+// Activates only if Option B of PCH-02-010 lands (offscreen GET_CLIPBOARD_HASH8+// + clearClipboardNow match check + clipboardRead permission). Do NOT9+// integrate under Option A. Imports are written in destination form; this10+// file is excluded from the suite while it lives under src/drafts/.11+12+import { beforeEach, describe, expect, it, vi } from "vitest";13+14+type StorageShape = Record<string, unknown>;15+16+async function sha256Hex(value: string): Promise<string> {17+  const digest = await crypto.subtle.digest(18+    "SHA-256",19+    new TextEncoder().encode(value),20+  );21+  return Array.from(new Uint8Array(digest))22+    .map((b) => b.toString(16).padStart(2, "0"))23+    .join("");24+}25+26+describe("PCH-02-010 — clipboard clear hash match", () => {27+  let storage: StorageShape;28+  let session: StorageShape;29+  let sendMessage: ReturnType<typeof vi.fn>;30+  let alarmsCreate: ReturnType<typeof vi.fn>;31+  let alarmsClear: ReturnType<typeof vi.fn>;32+  let clipboardWriteText: ReturnType<typeof vi.fn>;33+  /** What the mocked offscreen document replies to GET_CLIPBOARD_HASH. */34+  let clipboardHashReply: { ok: boolean; hash?: string };35+  let clearMessageCount: () => number;36+37+  beforeEach(() => {38+    vi.resetModules();39+    storage = {};40+    session = {};41+    clipboardHashReply = { ok: false };42+    clipboardWriteText = vi.fn(async () => undefined);43+    alarmsCreate = vi.fn(async () => undefined);44+    alarmsClear = vi.fn(async () => true);45+46+    sendMessage = vi.fn(async (msg: { type?: string }) => {47+      if (msg?.type === "GET_CLIPBOARD_HASH") return clipboardHashReply;48+      if (msg?.type === "CLEAR_CLIPBOARD") return { ok: true };49+      return { ok: true };50+    });51+    clearMessageCount = () =>52+      sendMessage.mock.calls.filter(53+        (c: Array<{ type?: string }>) => c[0]?.type === "CLEAR_CLIPBOARD",54+      ).length;55+56+    const chromeMock = {57+      runtime: { sendMessage },58+      offscreen: {59+        createDocument: vi.fn(async () => undefined),60+        closeDocument: vi.fn(async () => undefined),61+        Reason: { CLIPBOARD: "CLIPBOARD" },62+      },63+      tabs: { query: vi.fn(async () => []) },64+      scripting: { executeScript: vi.fn(async () => []) },65+      alarms: {66+        clear: alarmsClear,67+        create: alarmsCreate,68+        onAlarm: { addListener: vi.fn() },69+      },70+      idle: {71+        setDetectionInterval: vi.fn(),72+        onStateChanged: { addListener: vi.fn() },73+      },74+      storage: {75+        local: {76+          get: vi.fn(async () => storage),77+          set: vi.fn(async (next: Record<string, unknown>) => {78+            Object.assign(storage, next);79+          }),80+        },81+        session: {82+          get: vi.fn(async (key: string) => ({ [key]: session[key] })),83+          set: vi.fn(async (next: Record<string, unknown>) => {84+            Object.assign(session, next);85+          }),86+          remove: vi.fn(async (key: string) => {87+            delete session[key];88+          }),89+        },90+      },91+    } as unknown as typeof chrome;92+93+    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;94+    vi.stubGlobal("navigator", {95+      clipboard: { writeText: clipboardWriteText },96+    });97+  });98+99+  it("clears when the live clipboard hash matches the parked hash", async () => {100+    session["clipboard-clear-hash"] = await sha256Hex("hunter2-secret");101+    clipboardHashReply = { ok: true, hash: await sha256Hex("hunter2-secret") };102+103+    const clipboard = await import("./clipboard");104+    await clipboard.clearClipboardNow();105+106+    expect(clearMessageCount()).toBe(1);107+    expect(session["clipboard-clear-hash"]).toBeUndefined();108+  });109+110+  it("skips the clear when the user already replaced the clipboard, and tears down bookkeeping", async () => {111+    session["clipboard-clear-hash"] = await sha256Hex("hunter2-secret");112+    clipboardHashReply = { ok: true, hash: await sha256Hex("grocery list") };113+114+    const clipboard = await import("./clipboard");115+    await clipboard.clearClipboardNow();116+117+    // No CLEAR_CLIPBOARD message, no fallback write — the user's value survives.118+    expect(clearMessageCount()).toBe(0);119+    expect(clipboardWriteText).not.toHaveBeenCalled();120+    // The clear is no longer owed: hash gone, alarm dropped.121+    expect(session["clipboard-clear-hash"]).toBeUndefined();122+    expect(alarmsClear).toHaveBeenCalledWith("peach-clipboard-clear-current");123+  });124+125+  it("clears anyway when the clipboard hash cannot be read (conservative fallback)", async () => {126+    session["clipboard-clear-hash"] = await sha256Hex("hunter2-secret");127+    clipboardHashReply = { ok: false };128+129+    const clipboard = await import("./clipboard");130+    await clipboard.clearClipboardNow();131+132+    expect(clearMessageCount()).toBe(1);133+    expect(session["clipboard-clear-hash"]).toBeUndefined();134+  });135+136+  it("keeps the unconditional clear on the no-offscreen (Firefox) path", async () => {137+    delete (globalThis.chrome as unknown as Record<string, unknown>).offscreen;138+    session["clipboard-clear-hash"] = await sha256Hex("hunter2-secret");139+140+    const clipboard = await import("./clipboard");141+    await clipboard.clearClipboardNow();142+143+    // No offscreen surface → no hash check possible → straight to the144+    // active-tab/navigator write of "" (pre-fix behaviour preserved).145+    expect(clearMessageCount()).toBe(0);146+    expect(session["clipboard-clear-hash"]).toBeUndefined();147+  });148+});

view file