clipboard.ts
1// Clipboard write + reliable auto-clear — audit fix HV-2.2//3// The pre-fix implementation scheduled the clear with a bare `setTimeout`,4// which does not survive MV3 service-worker eviction, and performed the5// clear via `executeScript` into the *active tab*, which fails on6// restricted pages (chrome://, web store) — both failure modes left the7// secret on the OS clipboard indefinitely with the error swallowed.8//9// Now:10// - The clear is scheduled with chrome.alarms (survives worker restarts).11// - A best-effort setTimeout still runs as a fast path for sub-30s12// settings (chrome.alarms resolution floor is 0.5 min); the alarm is13// the guaranteed backstop.14// - The clear itself goes through an offscreen document15// (offscreen.html/offscreen.ts) — a stable extension-owned surface —16// with executeScript into the active tab as the Firefox fallback17// (Firefox MV2/MV3 has no chrome.offscreen).18// - A SHA-256 hash of the copied value (never the plaintext) is parked in19// chrome.storage.session so a restarted worker knows a clear is owed.2021import { getSettings } from "./vault";2223const DEFAULT_CLEAR_SECONDS = 30;24export const CLIPBOARD_CLEAR_ALARM = "peach-clipboard-clear-current";25const CLEAR_HASH_KEY = "clipboard-clear-hash";26// chrome.alarms cannot fire sooner than 30s in stable Chrome. For shorter27// user settings the setTimeout fast path handles the early clear and the28// alarm remains as the eviction-proof backstop.29const MIN_ALARM_MINUTES = 0.5;3031let clipboardClearTimer: ReturnType<typeof setTimeout> | null = null;3233async function writeToClipboard(text: string): Promise<void> {34 // Prefer chrome.scripting.executeScript which reliably works in MV3 service workers35 // by running in the context of the active tab page, falling back to direct write.36 try {37 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });38 if (tab?.id) {39 await chrome.scripting.executeScript({40 target: { tabId: tab.id },41 func: (value: string) => navigator.clipboard.writeText(value),42 args: [text],43 });44 return;45 }46 } catch {47 // Fall through to direct write48 }49 // Fallback: direct navigator.clipboard (may fail if service worker is backgrounded)50 await navigator.clipboard.writeText(text);51}5253async function hashForVerification(value: string): Promise<string> {54 const digest = await crypto.subtle.digest(55 "SHA-256",56 new TextEncoder().encode(value),57 );58 return Array.from(new Uint8Array(digest))59 .map((b) => b.toString(16).padStart(2, "0"))60 .join("");61}6263export async function copyToClipboard(64 text: string,65 _label: string,66 alreadyWritten = false,67): Promise<void> {68 if (clipboardClearTimer) {69 clearTimeout(clipboardClearTimer);70 clipboardClearTimer = null;71 }72 // A second copy supersedes the first: cancel the previous alarm so the73 // old timer can't clear the new value early.74 await chrome.alarms.clear(CLIPBOARD_CLEAR_ALARM).catch(() => false);7576 // The popup/content caller may have already written the clipboard from a77 // focused document with a fresh user gesture — that path is more reliable78 // than the SW's `executeScript` into the active tab. When that happens, we79 // only need to schedule the auto-clear here.80 if (!alreadyWritten) {81 await writeToClipboard(text);82 }8384 const settingsSeconds = getSettings().clipboardClearSeconds;85 const clearAfterMs =86 (typeof settingsSeconds === "number" && settingsSeconds > 087 ? settingsSeconds88 : DEFAULT_CLEAR_SECONDS) * 1000;8990 // Park a hash (never plaintext) so a cold-started worker knows a clear is91 // owed. storage.session is memory-backed and cleared on browser exit.92 try {93 const hash = await hashForVerification(text);94 await chrome.storage.session.set({ [CLEAR_HASH_KEY]: hash });95 } catch {96 // Hash bookkeeping is best-effort; the alarm still fires without it.97 }9899 // Eviction-proof backstop (HV-2a). delayInMinutes floors at 0.5 in stable100 // Chrome; for shorter settings the setTimeout below clears earlier and the101 // alarm's clear of an already-empty clipboard is harmless.102 await chrome.alarms.create(CLIPBOARD_CLEAR_ALARM, {103 delayInMinutes: Math.max(clearAfterMs / 60_000, MIN_ALARM_MINUTES),104 });105106 // Fast path while the worker stays alive — honors sub-30s settings.107 clipboardClearTimer = setTimeout(async () => {108 clipboardClearTimer = null;109 await clearClipboardNow();110 }, clearAfterMs);111}112113/**114 * Clear the clipboard through the most reliable available surface and tear115 * down the bookkeeping. Called by the setTimeout fast path and by the116 * onAlarm backstop (background entry point).117 */118export async function clearClipboardNow(): Promise<void> {119 let cleared = false;120121 // Chrome: offscreen document — works with no tab and on restricted pages.122 if (chrome.offscreen?.createDocument) {123 try {124 await chrome.offscreen.createDocument({125 url: "offscreen.html",126 reasons: [chrome.offscreen.Reason.CLIPBOARD],127 justification: "Clear sensitive clipboard content after timeout",128 });129 } catch {130 // Already exists — fine, message it directly.131 }132 try {133 const res = (await chrome.runtime.sendMessage({134 type: "CLEAR_CLIPBOARD",135 })) as { ok?: boolean } | undefined;136 cleared = res?.ok === true;137 } catch {138 cleared = false;139 }140 // Close the offscreen document so it doesn't linger; ignore failures.141 try {142 await chrome.offscreen.closeDocument();143 } catch {144 /* not open / race — fine */145 }146 }147148 // Firefox (no chrome.offscreen) or offscreen failure: active-tab path.149 if (!cleared) {150 try {151 await writeToClipboard("");152 cleared = true;153 } catch {154 cleared = false;155 }156 }157158 if (cleared) {159 await chrome.storage.session.remove(CLEAR_HASH_KEY).catch(() => undefined);160 await chrome.alarms.clear(CLIPBOARD_CLEAR_ALARM).catch(() => false);161 } else {162 // Could not clear (e.g. only a chrome:// tab focused on Firefox).163 // Re-arm a short retry alarm rather than swallowing the failure —164 // the secret must not silently persist.165 await chrome.alarms.create(CLIPBOARD_CLEAR_ALARM, {166 delayInMinutes: MIN_ALARM_MINUTES,167 });168 }169}170171/**172 * Wire the eviction-proof clear. Must be called during the worker's initial173 * synchronous evaluation (same rule as the onMessage listener) — from174 * entry.ts start().175 */176export function setupClipboardClearAlarm(): void {177 chrome.alarms.onAlarm.addListener((alarm) => {178 if (alarm.name !== CLIPBOARD_CLEAR_ALARM) return;179 void clearClipboardNow();180 });181}