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

10.4 KB · raw

rendered · source

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:

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:

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)

// 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

// "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:

// 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:

export async function clearClipboardNow(): Promise<void> {
  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):

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)