PCH-02-010-clipboard-hash-match.patch.md

10.4 KB · raw

rendered · source

1# PCH-02-010 — Clipboard auto-clear verifies the parked hash before clearing23**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-01089## ⚠ Integration gate: this fix requires the `clipboardRead` permission1011Verified constraints of the only available read surfaces:1213- `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).2021`clipboardRead` surfaces in the Chrome Web Store as **"Read data you copy and22paste"** — a visible permission expansion for a password manager, and a23store re-review trigger. The current unconditional clear is conservative and24costs only a minor UX surprise (the review's own framing). **Decision for the25integrator:**2627- **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.3637Recommendation: **Option A** for v4.0.1; hold Option B in this draft until a38release where the permission expansion is worth bundling. Both are complete39below — whichever lands, the parked-hash comment stops reading as dead code.4041---4243## Option A — comment-only edit (clipboard.ts:18-19)4445```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.4950// 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```6465Option A save message:66`docs: PCH-02-010 document unconditional clipboard clear as deliberate — hash-match requires clipboardRead permission, implementation parked`6768---6970## Option B — full hash-match implementation7172### B1. manifest.json — permission7374```json75// "permissions" array: ADD76    "clipboardRead",77```7879(`manifest.firefox.json` is NOT changed — the Firefox path has no offscreen80surface and keeps the unconditional clear; see B3.)8182### B2. offscreen.ts — hash the live clipboard locally, return only the hash8384Append a second listener (same file, after the CLEAR_CLIPBOARD listener).85The plaintext never crosses the message channel — the offscreen document86hashes in place and replies with hex SHA-256 only:8788```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.94chrome.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```131132### B3. clipboard.ts — compare before clearing in `clearClipboardNow`133134Replace the Chrome/offscreen branch (clipboard.ts:124-155). The offscreen135document is created once and reused for both the hash check and the clear:136137```typescript138export async function clearClipboardNow(): Promise<void> {139  let cleared = false;140  let skipClear = false;141142  // 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    }156157    // 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    }177178    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  }195196  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  }202203  // 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  }213214  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```227228Behavioural 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.243244### B4. Tests (Option B only)245246`clipboard.hash-match.test.ts` (in this folder) — 4 tests: matching hash →247CLEAR_CLIPBOARD sent + bookkeeping torn down; mismatching hash → clear248skipped + bookkeeping torn down; hash-read failure → cleared anyway249(conservative); Firefox path (no chrome.offscreen) → unconditional clear250unchanged. Destination: `src/background/clipboard.hash-match.test.ts`.251252Option 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)`