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
↓ peach-browser-927a4bd8.tar.gzatlas at this save
files changed2 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 −0src/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)`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+});