clipboard.ts

7.7 KB · raw

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    // Best-effort: a clipboard write failure (e.g. no active tab, navigator.clipboard82    // unavailable in SW context) must not prevent the clear alarm from being scheduled.83    await writeToClipboard(text).catch(() => {});84  }8586  const settingsSeconds = getSettings().clipboardClearSeconds;87  const clearAfterMs =88    (typeof settingsSeconds === "number" && settingsSeconds > 089      ? settingsSeconds90      : DEFAULT_CLEAR_SECONDS) * 1000;9192  // Park a hash (never plaintext) so a cold-started worker knows a clear is93  // owed. storage.session is memory-backed and cleared on browser exit.94  try {95    const hash = await hashForVerification(text);96    await chrome.storage.session.set({ [CLEAR_HASH_KEY]: hash });97  } catch {98    // Hash bookkeeping is best-effort; the alarm still fires without it.99  }100101  // Eviction-proof backstop (HV-2a). delayInMinutes floors at 0.5 in stable102  // Chrome; for shorter settings the setTimeout below clears earlier and the103  // alarm's clear of an already-empty clipboard is harmless.104  await chrome.alarms.create(CLIPBOARD_CLEAR_ALARM, {105    delayInMinutes: Math.max(clearAfterMs / 60_000, MIN_ALARM_MINUTES),106  });107108  // Fast path while the worker stays alive — honors sub-30s settings.109  clipboardClearTimer = setTimeout(async () => {110    clipboardClearTimer = null;111    await clearClipboardNow();112  }, clearAfterMs);113}114115/**116 * Clear the clipboard through the most reliable available surface and tear117 * down the bookkeeping. Called by the setTimeout fast path and by the118 * onAlarm backstop (background entry point).119 */120export async function clearClipboardNow(): Promise<void> {121  let cleared = false;122123  // Chrome: offscreen document — works with no tab and on restricted pages.124  if (chrome.offscreen?.createDocument) {125    try {126      await chrome.offscreen.createDocument({127        // crxjs emits the offscreen page preserving its source path under the128        // Chrome dist root (see vite.config.ts offscreen input). Keep this URL129        // aligned with that emitted location.130        url: "src/background/offscreen.html",131        reasons: [chrome.offscreen.Reason.CLIPBOARD],132        justification: "Clear sensitive clipboard content after timeout",133      });134    } catch {135      // Already exists — fine, message it directly.136    }137    try {138      const res = (await chrome.runtime.sendMessage({139        type: "CLEAR_CLIPBOARD",140      })) as { ok?: boolean } | undefined;141      cleared = res?.ok === true;142    } catch {143      cleared = false;144    }145    // Close the offscreen document so it doesn't linger; ignore failures.146    try {147      await chrome.offscreen.closeDocument();148    } catch {149      /* not open / race — fine */150    }151  }152153  // Firefox (no chrome.offscreen) or offscreen failure: active-tab path.154  if (!cleared) {155    try {156      await writeToClipboard("");157      cleared = true;158    } catch {159      cleared = false;160    }161  }162163  if (cleared) {164    await chrome.storage.session.remove(CLEAR_HASH_KEY).catch(() => undefined);165    await chrome.alarms.clear(CLIPBOARD_CLEAR_ALARM).catch(() => false);166  } else {167    // Could not clear (e.g. only a chrome:// tab focused on Firefox).168    // Re-arm a short retry alarm rather than swallowing the failure —169    // the secret must not silently persist.170    await chrome.alarms.create(CLIPBOARD_CLEAR_ALARM, {171      delayInMinutes: MIN_ALARM_MINUTES,172    });173  }174}175176/**177 * Wire the eviction-proof clear. Must be called during the worker's initial178 * synchronous evaluation (same rule as the onMessage listener) — from179 * entry.ts start().180 */181export function setupClipboardClearAlarm(): void {182  chrome.alarms.onAlarm.addListener((alarm) => {183    if (alarm.name !== CLIPBOARD_CLEAR_ALARM) return;184    // PCH-01-021: only clear if a clear is still owed. CLEAR_HASH_KEY is set on185    // copy and removed once a clear succeeds; its absence means the fast-path186    // setTimeout (or a prior alarm) already cleared, so a cold worker woken by a187    // stale alarm can skip the offscreen-document churn.188    void (async () => {189      try {190        const stored = await chrome.storage.session.get(CLEAR_HASH_KEY);191        if (stored?.[CLEAR_HASH_KEY] === undefined) {192          // Nothing owed — drop the stale alarm and return.193          await chrome.alarms.clear(CLIPBOARD_CLEAR_ALARM).catch(() => false);194          return;195        }196      } catch {197        // session.get failed — fall through and clear anyway. Erring toward198        // clearing keeps the secret-must-not-persist invariant.199      }200      await clearClipboardNow();201    })();202  });203}