# PCH-02-010 — Clipboard auto-clear verifies the parked hash before clearing **Severity:** Informational **Target files:** `src/background/clipboard.ts:124-178` (`clearClipboardNow`), `src/background/offscreen.ts`, `manifest.json` (⚠ permission), comment in `clipboard.ts:18-19` **Report:** peach-code-review.md, Finding PCH-02-010 ## ⚠ Integration gate: this fix requires the `clipboardRead` permission Verified constraints of the only available read surfaces: - `navigator.clipboard.readText()` in an offscreen document rejects with *"Document is not focused"* — offscreen documents are never focused. Not reliable. - The workable offscreen read path is `document.execCommand("paste")` into a textarea (the documented offscreen-clipboard technique), which **requires adding `"clipboardRead"` to `manifest.json` permissions** (the manifest currently grants `clipboardWrite` only). `clipboardRead` surfaces in the Chrome Web Store as **"Read data you copy and paste"** — a visible permission expansion for a password manager, and a store re-review trigger. The current unconditional clear is conservative and costs only a minor UX surprise (the review's own framing). **Decision for the integrator:** - **Option A (no new permission):** land only the comment fix below — the parked hash is *not* dead bookkeeping (PCH-01-021 made it the clear-owed flag); state explicitly that match-before-clear was evaluated and rejected because the required `clipboardRead` grant is a worse privacy posture than the UX cost it saves. This satisfies the brief's fallback ("document this and clear unconditionally … acceptable behavior"). - **Option B (full fix, below):** hash-match before clearing, with the permission addition flagged in the release notes. The hash comparison is designed so plaintext never leaves the offscreen document. Recommendation: **Option A** for v4.0.1; hold Option B in this draft until a release where the permission expansion is worth bundling. Both are complete below — whichever lands, the parked-hash comment stops reading as dead code. --- ## Option A — comment-only edit (clipboard.ts:18-19) ```typescript // BEFORE (clipboard.ts:18-19) // - A SHA-256 hash of the copied value (never the plaintext) is parked in // chrome.storage.session so a restarted worker knows a clear is owed. // AFTER // - A SHA-256 hash of the copied value (never the plaintext) is parked in // chrome.storage.session so a restarted worker knows a clear is owed // (PCH-01-021: the alarm handler skips the offscreen churn when no clear // is owed — the hash IS load-bearing, as the owed-flag, not dead state). // - PCH-02-010: the clear is deliberately UNCONDITIONAL — we do not read // the clipboard to check it still holds our value, because every viable // read surface (execCommand("paste") in the offscreen document) requires // the `clipboardRead` permission ("Read data you copy and paste" in the // store listing) — a worse privacy posture than occasionally wiping a // value the user copied later. Secret-must-not-persist wins the tie. A // ready hash-match implementation is parked in // src/drafts/PCH-02-FIXES/PCH-02-010-clipboard-hash-match.patch.md. ``` Option A save message: `docs: PCH-02-010 document unconditional clipboard clear as deliberate — hash-match requires clipboardRead permission, implementation parked` --- ## Option B — full hash-match implementation ### B1. manifest.json — permission ```json // "permissions" array: ADD "clipboardRead", ``` (`manifest.firefox.json` is NOT changed — the Firefox path has no offscreen surface and keeps the unconditional clear; see B3.) ### B2. offscreen.ts — hash the live clipboard locally, return only the hash Append a second listener (same file, after the CLEAR_CLIPBOARD listener). The plaintext never crosses the message channel — the offscreen document hashes in place and replies with hex SHA-256 only: ```typescript // PCH-02-010: report a SHA-256 of the current clipboard text so the // background can decide whether the parked auto-clear still applies. // Hashing happens HERE so the clipboard plaintext (which may be something // unrelated and sensitive the user copied later) never crosses the // extension message channel. chrome.runtime.onMessage.addListener( (msg: unknown, _sender, sendResponse: (r: unknown) => void) => { const type = (msg as { type?: string } | null)?.type; if (type !== "GET_CLIPBOARD_HASH") return undefined; void (async () => { try { let text: string; try { text = await navigator.clipboard.readText(); } catch { // Offscreen docs are never focused, so readText usually rejects; // execCommand("paste") into a textarea is the documented offscreen // read path (requires the clipboardRead permission). const ta = document.createElement("textarea"); document.body.appendChild(ta); ta.focus(); const ok = document.execCommand("paste"); text = ta.value; ta.remove(); if (!ok) throw new Error("paste_failed"); } const digest = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(text), ); const hash = Array.from(new Uint8Array(digest)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); sendResponse({ ok: true, hash }); } catch { sendResponse({ ok: false }); } })(); return true; // async sendResponse }, ); ``` ### B3. clipboard.ts — compare before clearing in `clearClipboardNow` Replace the Chrome/offscreen branch (clipboard.ts:124-155). The offscreen document is created once and reused for both the hash check and the clear: ```typescript export async function clearClipboardNow(): Promise { let cleared = false; let skipClear = false; // Chrome: offscreen document — works with no tab and on restricted pages. if (chrome.offscreen?.createDocument) { try { await chrome.offscreen.createDocument({ // crxjs emits the offscreen page preserving its source path under the // Chrome dist root (see vite.config.ts offscreen input). Keep this URL // aligned with that emitted location. url: "src/background/offscreen.html", reasons: [chrome.offscreen.Reason.CLIPBOARD], justification: "Clear sensitive clipboard content after timeout", }); } catch { // Already exists — fine, message it directly. } // PCH-02-010: the parked SHA-256 is now also a MATCH check, not only the // clear-owed flag (PCH-01-021). If the clipboard verifiably holds a // DIFFERENT value, the user already replaced our copy — wiping would // destroy their data for no confidentiality gain. Unreadable clipboard // (no parked hash / read failure / no response) falls through to the // clear: secret-must-not-persist wins every ambiguous case. try { const stored = await chrome.storage.session.get(CLEAR_HASH_KEY); const parked = stored?.[CLEAR_HASH_KEY]; if (typeof parked === "string") { const res = (await chrome.runtime.sendMessage({ type: "GET_CLIPBOARD_HASH", })) as { ok?: boolean; hash?: string } | undefined; if (res?.ok === true && typeof res.hash === "string" && res.hash !== parked) { skipClear = true; } } } catch { // Comparison is best-effort — clear anyway. } if (!skipClear) { try { const res = (await chrome.runtime.sendMessage({ type: "CLEAR_CLIPBOARD", })) as { ok?: boolean } | undefined; cleared = res?.ok === true; } catch { cleared = false; } } // Close the offscreen document so it doesn't linger; ignore failures. try { await chrome.offscreen.closeDocument(); } catch { /* not open / race — fine */ } } if (skipClear) { // The clipboard no longer holds our value — the clear is no longer owed. await chrome.storage.session.remove(CLEAR_HASH_KEY).catch(() => undefined); await chrome.alarms.clear(CLIPBOARD_CLEAR_ALARM).catch(() => false); return; } // Firefox (no chrome.offscreen) or offscreen failure: active-tab path. // No read surface here — the clear stays unconditional (pre-fix behaviour). if (!cleared) { try { await writeToClipboard(""); cleared = true; } catch { cleared = false; } } if (cleared) { await chrome.storage.session.remove(CLEAR_HASH_KEY).catch(() => undefined); await chrome.alarms.clear(CLIPBOARD_CLEAR_ALARM).catch(() => false); } else { // Could not clear (e.g. only a chrome:// tab focused on Firefox). // Re-arm a short retry alarm rather than swallowing the failure — // the secret must not silently persist. await chrome.alarms.create(CLIPBOARD_CLEAR_ALARM, { delayInMinutes: MIN_ALARM_MINUTES, }); } } ``` Behavioural notes (all verified against the current file): - The fast-path `setTimeout` and the alarm backstop both call `clearClipboardNow`, so both honour the match check — no second wiring point. - If the parked hash is missing (bookkeeping write failed on copy), the check is skipped and the clear proceeds — identical to today. - Deviation from the brief's sketch: the brief hashes with BLAKE3; the parked hash is and stays SHA-256 (`hashForVerification`, clipboard.ts:61-69) — the comparison must use the hash that is actually parked. - Deviation from the brief's sketch: the brief returns `true` ("clear") when the parked hash is absent; the existing PCH-01-021 alarm handler already treats absent-hash as "nothing owed" and skips. This patch preserves that: absence short-circuits in the ALARM handler (unchanged), and in `clearClipboardNow` absence just means "no comparison possible → clear", which only the fast-path timer reaches. ### B4. Tests (Option B only) `clipboard.hash-match.test.ts` (in this folder) — 4 tests: matching hash → CLEAR_CLIPBOARD sent + bookkeeping torn down; mismatching hash → clear skipped + bookkeeping torn down; hash-read failure → cleared anyway (conservative); Firefox path (no chrome.offscreen) → unconditional clear unchanged. Destination: `src/background/clipboard.hash-match.test.ts`. Option B save message: `fix: PCH-02-010 clipboard auto-clear verifies parked-hash match via offscreen SHA-256 before clearing — adds clipboardRead permission (Chrome only)`