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;3031// The auto-clear delay in seconds: the user's setting when it is a positive32// number, otherwise the 30s default. Single source for both the clear timer33// and the COPY response that tells the content script how long to advertise.34export function getEffectiveClearSeconds(): number {35 const s = getSettings().clipboardClearSeconds;36 return typeof s === "number" && s > 0 ? s : DEFAULT_CLEAR_SECONDS;37}3839let clipboardClearTimer: ReturnType<typeof setTimeout> | null = null;4041async function writeToClipboard(text: string): Promise<void> {42 // Prefer chrome.scripting.executeScript which reliably works in MV3 service workers43 // by running in the context of the active tab page, falling back to direct write.44 try {45 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });46 if (tab?.id) {47 await chrome.scripting.executeScript({48 target: { tabId: tab.id },49 func: (value: string) => navigator.clipboard.writeText(value),50 args: [text],51 });52 return;53 }54 } catch {55 // Fall through to direct write56 }57 // Fallback: direct navigator.clipboard (may fail if service worker is backgrounded)58 await navigator.clipboard.writeText(text);59}6061async function hashForVerification(value: string): Promise<string> {62 const digest = await crypto.subtle.digest(63 "SHA-256",64 new TextEncoder().encode(value),65 );66 return Array.from(new Uint8Array(digest))67 .map((b) => b.toString(16).padStart(2, "0"))68 .join("");69}7071export async function copyToClipboard(72 text: string,73 _label: string,74 alreadyWritten = false,75): Promise<void> {76 if (clipboardClearTimer) {77 clearTimeout(clipboardClearTimer);78 clipboardClearTimer = null;79 }80 // A second copy supersedes the first: cancel the previous alarm so the81 // old timer can't clear the new value early.82 await chrome.alarms.clear(CLIPBOARD_CLEAR_ALARM).catch(() => false);8384 // The popup/content caller may have already written the clipboard from a85 // focused document with a fresh user gesture — that path is more reliable86 // than the SW's `executeScript` into the active tab. When that happens, we87 // only need to schedule the auto-clear here.88 if (!alreadyWritten) {89 // Best-effort: a clipboard write failure (e.g. no active tab, navigator.clipboard90 // unavailable in SW context) must not prevent the clear alarm from being scheduled.91 await writeToClipboard(text).catch(() => {});92 }9394 const clearAfterMs = getEffectiveClearSeconds() * 1000;9596 // Park a hash (never plaintext) so a cold-started worker knows a clear is97 // owed. storage.session is memory-backed and cleared on browser exit.98 try {99 const hash = await hashForVerification(text);100 await chrome.storage.session.set({ [CLEAR_HASH_KEY]: hash });101 } catch {102 // Hash bookkeeping is best-effort; the alarm still fires without it.103 }104105 // Eviction-proof backstop (HV-2a). delayInMinutes floors at 0.5 in stable106 // Chrome; for shorter settings the setTimeout below clears earlier and the107 // alarm's clear of an already-empty clipboard is harmless.108 await chrome.alarms.create(CLIPBOARD_CLEAR_ALARM, {109 delayInMinutes: Math.max(clearAfterMs / 60_000, MIN_ALARM_MINUTES),110 });111112 // Fast path while the worker stays alive — honors sub-30s settings.113 clipboardClearTimer = setTimeout(async () => {114 clipboardClearTimer = null;115 await clearClipboardNow();116 }, clearAfterMs);117}118119/**120 * Clear the clipboard through the most reliable available surface and tear121 * down the bookkeeping. Called by the setTimeout fast path and by the122 * onAlarm backstop (background entry point).123 */124export async function clearClipboardNow(): Promise<void> {125 let cleared = false;126127 // Chrome: offscreen document — works with no tab and on restricted pages.128 if (chrome.offscreen?.createDocument) {129 try {130 await chrome.offscreen.createDocument({131 // crxjs emits the offscreen page preserving its source path under the132 // Chrome dist root (see vite.config.ts offscreen input). Keep this URL133 // aligned with that emitted location.134 url: "src/background/offscreen.html",135 reasons: [chrome.offscreen.Reason.CLIPBOARD],136 justification: "Clear sensitive clipboard content after timeout",137 });138 } catch {139 // Already exists — fine, message it directly.140 }141 try {142 const res = (await chrome.runtime.sendMessage({143 type: "CLEAR_CLIPBOARD",144 })) as { ok?: boolean } | undefined;145 cleared = res?.ok === true;146 } catch {147 cleared = false;148 }149 // Close the offscreen document so it doesn't linger; ignore failures.150 try {151 await chrome.offscreen.closeDocument();152 } catch {153 /* not open / race — fine */154 }155 }156157 // Firefox (no chrome.offscreen) or offscreen failure: active-tab path.158 if (!cleared) {159 try {160 await writeToClipboard("");161 cleared = true;162 } catch {163 cleared = false;164 }165 }166167 if (cleared) {168 await chrome.storage.session.remove(CLEAR_HASH_KEY).catch(() => undefined);169 await chrome.alarms.clear(CLIPBOARD_CLEAR_ALARM).catch(() => false);170 } else {171 // Could not clear (e.g. only a chrome:// tab focused on Firefox).172 // Re-arm a short retry alarm rather than swallowing the failure —173 // the secret must not silently persist.174 await chrome.alarms.create(CLIPBOARD_CLEAR_ALARM, {175 delayInMinutes: MIN_ALARM_MINUTES,176 });177 }178}179180/**181 * Wire the eviction-proof clear. Must be called during the worker's initial182 * synchronous evaluation (same rule as the onMessage listener) — from183 * entry.ts start().184 */185export function setupClipboardClearAlarm(): void {186 chrome.alarms.onAlarm.addListener((alarm) => {187 if (alarm.name !== CLIPBOARD_CLEAR_ALARM) return;188 // PCH-01-021: only clear if a clear is still owed. CLEAR_HASH_KEY is set on189 // copy and removed once a clear succeeds; its absence means the fast-path190 // setTimeout (or a prior alarm) already cleared, so a cold worker woken by a191 // stale alarm can skip the offscreen-document churn.192 void (async () => {193 try {194 const stored = await chrome.storage.session.get(CLEAR_HASH_KEY);195 if (stored?.[CLEAR_HASH_KEY] === undefined) {196 // Nothing owed — drop the stale alarm and return.197 await chrome.alarms.clear(CLIPBOARD_CLEAR_ALARM).catch(() => false);198 return;199 }200 } catch {201 // session.get failed — fall through and clear anyway. Erring toward202 // clearing keeps the secret-must-not-persist invariant.203 }204 await clearClipboardNow();205 })();206 });207}