// Clipboard write + reliable auto-clear — audit fix HV-2. // // The pre-fix implementation scheduled the clear with a bare `setTimeout`, // which does not survive MV3 service-worker eviction, and performed the // clear via `executeScript` into the *active tab*, which fails on // restricted pages (chrome://, web store) — both failure modes left the // secret on the OS clipboard indefinitely with the error swallowed. // // Now: // - The clear is scheduled with chrome.alarms (survives worker restarts). // - A best-effort setTimeout still runs as a fast path for sub-30s // settings (chrome.alarms resolution floor is 0.5 min); the alarm is // the guaranteed backstop. // - The clear itself goes through an offscreen document // (offscreen.html/offscreen.ts) — a stable extension-owned surface — // with executeScript into the active tab as the Firefox fallback // (Firefox MV2/MV3 has no chrome.offscreen). // - 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 offscreen churn when no clear is // owed — the hash is load-bearing as an 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 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. import { getSettings } from "./vault"; const DEFAULT_CLEAR_SECONDS = 30; export const CLIPBOARD_CLEAR_ALARM = "peach-clipboard-clear-current"; const CLEAR_HASH_KEY = "clipboard-clear-hash"; // chrome.alarms cannot fire sooner than 30s in stable Chrome. For shorter // user settings the setTimeout fast path handles the early clear and the // alarm remains as the eviction-proof backstop. const MIN_ALARM_MINUTES = 0.5; // The auto-clear delay in seconds: the user's setting when it is a positive // number, otherwise the 30s default. Single source for both the clear timer // and the COPY response that tells the content script how long to advertise. export function getEffectiveClearSeconds(): number { const s = getSettings().clipboardClearSeconds; return typeof s === "number" && s > 0 ? s : DEFAULT_CLEAR_SECONDS; } let clipboardClearTimer: ReturnType | null = null; async function writeToClipboard(text: string): Promise { // Prefer chrome.scripting.executeScript which reliably works in MV3 service workers // by running in the context of the active tab page, falling back to direct write. try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (tab?.id) { await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: (value: string) => navigator.clipboard.writeText(value), args: [text], }); return; } } catch { // Fall through to direct write } // Fallback: direct navigator.clipboard (may fail if service worker is backgrounded) await navigator.clipboard.writeText(text); } async function hashForVerification(value: string): Promise { const digest = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(value), ); return Array.from(new Uint8Array(digest)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } export async function copyToClipboard( text: string, _label: string, alreadyWritten = false, ): Promise { if (clipboardClearTimer) { clearTimeout(clipboardClearTimer); clipboardClearTimer = null; } // A second copy supersedes the first: cancel the previous alarm so the // old timer can't clear the new value early. await chrome.alarms.clear(CLIPBOARD_CLEAR_ALARM).catch(() => false); // The popup/content caller may have already written the clipboard from a // focused document with a fresh user gesture — that path is more reliable // than the SW's `executeScript` into the active tab. When that happens, we // only need to schedule the auto-clear here. if (!alreadyWritten) { // Best-effort: a clipboard write failure (e.g. no active tab, navigator.clipboard // unavailable in SW context) must not prevent the clear alarm from being scheduled. await writeToClipboard(text).catch(() => {}); } const clearAfterMs = getEffectiveClearSeconds() * 1000; // Park a hash (never plaintext) so a cold-started worker knows a clear is // owed. storage.session is memory-backed and cleared on browser exit. try { const hash = await hashForVerification(text); await chrome.storage.session.set({ [CLEAR_HASH_KEY]: hash }); } catch { // Hash bookkeeping is best-effort; the alarm still fires without it. } // Eviction-proof backstop (HV-2a). delayInMinutes floors at 0.5 in stable // Chrome; for shorter settings the setTimeout below clears earlier and the // alarm's clear of an already-empty clipboard is harmless. await chrome.alarms.create(CLIPBOARD_CLEAR_ALARM, { delayInMinutes: Math.max(clearAfterMs / 60_000, MIN_ALARM_MINUTES), }); // Fast path while the worker stays alive — honors sub-30s settings. clipboardClearTimer = setTimeout(async () => { clipboardClearTimer = null; await clearClipboardNow(); }, clearAfterMs); } /** * Clear the clipboard through the most reliable available surface and tear * down the bookkeeping. Called by the setTimeout fast path and by the * onAlarm backstop (background entry point). */ export async function clearClipboardNow(): Promise { let cleared = 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. } 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 */ } } // Firefox (no chrome.offscreen) or offscreen failure: active-tab path. 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, }); } } /** * Wire the eviction-proof clear. Must be called during the worker's initial * synchronous evaluation (same rule as the onMessage listener) — from * entry.ts start(). */ export function setupClipboardClearAlarm(): void { chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name !== CLIPBOARD_CLEAR_ALARM) return; // PCH-01-021: only clear if a clear is still owed. CLEAR_HASH_KEY is set on // copy and removed once a clear succeeds; its absence means the fast-path // setTimeout (or a prior alarm) already cleared, so a cold worker woken by a // stale alarm can skip the offscreen-document churn. void (async () => { try { const stored = await chrome.storage.session.get(CLEAR_HASH_KEY); if (stored?.[CLEAR_HASH_KEY] === undefined) { // Nothing owed — drop the stale alarm and return. await chrome.alarms.clear(CLIPBOARD_CLEAR_ALARM).catch(() => false); return; } } catch { // session.get failed — fall through and clear anyway. Erring toward // clearing keeps the secret-must-not-persist invariant. } await clearClipboardNow(); })(); }); }