PCH-02-010-clipboard-hash-match.patch.md
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"tomanifest.jsonpermissions (the manifest currently grantsclipboardWriteonly).
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
clipboardReadgrant 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)
// 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):
- The fast-path
setTimeoutand the alarm backstop both callclearClipboardNow, 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 inclearClipboardNowabsence 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)