clipboard.ts

8.5 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 owed20//     (PCH-01-021: the alarm handler skips offscreen churn when no clear is21//     owed — the hash is load-bearing as an owed flag, not dead state).22//   - PCH-02-010: the clear is deliberately unconditional. We do not read the23//     clipboard to check it still holds our value because every viable read24//     surface requires the `clipboardRead` permission ("Read data you copy and25//     paste" in the store listing), a worse privacy posture than occasionally26//     wiping a value the user copied later. Secret-must-not-persist wins.2728import { getSettings } from "./vault";2930const DEFAULT_CLEAR_SECONDS = 30;31export const CLIPBOARD_CLEAR_ALARM = "peach-clipboard-clear-current";32const CLEAR_HASH_KEY = "clipboard-clear-hash";33// chrome.alarms cannot fire sooner than 30s in stable Chrome. For shorter34// user settings the setTimeout fast path handles the early clear and the35// alarm remains as the eviction-proof backstop.36const MIN_ALARM_MINUTES = 0.5;3738// The auto-clear delay in seconds: the user's setting when it is a positive39// number, otherwise the 30s default. Single source for both the clear timer40// and the COPY response that tells the content script how long to advertise.41export function getEffectiveClearSeconds(): number {42  const s = getSettings().clipboardClearSeconds;43  return typeof s === "number" && s > 0 ? s : DEFAULT_CLEAR_SECONDS;44}4546let clipboardClearTimer: ReturnType<typeof setTimeout> | null = null;4748async function writeToClipboard(text: string): Promise<void> {49  // Prefer chrome.scripting.executeScript which reliably works in MV3 service workers50  // by running in the context of the active tab page, falling back to direct write.51  try {52    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });53    if (tab?.id) {54      await chrome.scripting.executeScript({55        target: { tabId: tab.id },56        func: (value: string) => navigator.clipboard.writeText(value),57        args: [text],58      });59      return;60    }61  } catch {62    // Fall through to direct write63  }64  // Fallback: direct navigator.clipboard (may fail if service worker is backgrounded)65  await navigator.clipboard.writeText(text);66}6768async function hashForVerification(value: string): Promise<string> {69  const digest = await crypto.subtle.digest(70    "SHA-256",71    new TextEncoder().encode(value),72  );73  return Array.from(new Uint8Array(digest))74    .map((b) => b.toString(16).padStart(2, "0"))75    .join("");76}7778export async function copyToClipboard(79  text: string,80  _label: string,81  alreadyWritten = false,82): Promise<void> {83  if (clipboardClearTimer) {84    clearTimeout(clipboardClearTimer);85    clipboardClearTimer = null;86  }87  // A second copy supersedes the first: cancel the previous alarm so the88  // old timer can't clear the new value early.89  await chrome.alarms.clear(CLIPBOARD_CLEAR_ALARM).catch(() => false);9091  // The popup/content caller may have already written the clipboard from a92  // focused document with a fresh user gesture — that path is more reliable93  // than the SW's `executeScript` into the active tab. When that happens, we94  // only need to schedule the auto-clear here.95  if (!alreadyWritten) {96    // Best-effort: a clipboard write failure (e.g. no active tab, navigator.clipboard97    // unavailable in SW context) must not prevent the clear alarm from being scheduled.98    await writeToClipboard(text).catch(() => {});99  }100101  const clearAfterMs = getEffectiveClearSeconds() * 1000;102103  // Park a hash (never plaintext) so a cold-started worker knows a clear is104  // owed. storage.session is memory-backed and cleared on browser exit.105  try {106    const hash = await hashForVerification(text);107    await chrome.storage.session.set({ [CLEAR_HASH_KEY]: hash });108  } catch {109    // Hash bookkeeping is best-effort; the alarm still fires without it.110  }111112  // Eviction-proof backstop (HV-2a). delayInMinutes floors at 0.5 in stable113  // Chrome; for shorter settings the setTimeout below clears earlier and the114  // alarm's clear of an already-empty clipboard is harmless.115  await chrome.alarms.create(CLIPBOARD_CLEAR_ALARM, {116    delayInMinutes: Math.max(clearAfterMs / 60_000, MIN_ALARM_MINUTES),117  });118119  // Fast path while the worker stays alive — honors sub-30s settings.120  clipboardClearTimer = setTimeout(async () => {121    clipboardClearTimer = null;122    await clearClipboardNow();123  }, clearAfterMs);124}125126/**127 * Clear the clipboard through the most reliable available surface and tear128 * down the bookkeeping. Called by the setTimeout fast path and by the129 * onAlarm backstop (background entry point).130 */131export async function clearClipboardNow(): Promise<void> {132  let cleared = false;133134  // Chrome: offscreen document — works with no tab and on restricted pages.135  if (chrome.offscreen?.createDocument) {136    try {137      await chrome.offscreen.createDocument({138        // crxjs emits the offscreen page preserving its source path under the139        // Chrome dist root (see vite.config.ts offscreen input). Keep this URL140        // aligned with that emitted location.141        url: "src/background/offscreen.html",142        reasons: [chrome.offscreen.Reason.CLIPBOARD],143        justification: "Clear sensitive clipboard content after timeout",144      });145    } catch {146      // Already exists — fine, message it directly.147    }148    try {149      const res = (await chrome.runtime.sendMessage({150        type: "CLEAR_CLIPBOARD",151      })) as { ok?: boolean } | undefined;152      cleared = res?.ok === true;153    } catch {154      cleared = false;155    }156    // Close the offscreen document so it doesn't linger; ignore failures.157    try {158      await chrome.offscreen.closeDocument();159    } catch {160      /* not open / race — fine */161    }162  }163164  // Firefox (no chrome.offscreen) or offscreen failure: active-tab path.165  if (!cleared) {166    try {167      await writeToClipboard("");168      cleared = true;169    } catch {170      cleared = false;171    }172  }173174  if (cleared) {175    await chrome.storage.session.remove(CLEAR_HASH_KEY).catch(() => undefined);176    await chrome.alarms.clear(CLIPBOARD_CLEAR_ALARM).catch(() => false);177  } else {178    // Could not clear (e.g. only a chrome:// tab focused on Firefox).179    // Re-arm a short retry alarm rather than swallowing the failure —180    // the secret must not silently persist.181    await chrome.alarms.create(CLIPBOARD_CLEAR_ALARM, {182      delayInMinutes: MIN_ALARM_MINUTES,183    });184  }185}186187/**188 * Wire the eviction-proof clear. Must be called during the worker's initial189 * synchronous evaluation (same rule as the onMessage listener) — from190 * entry.ts start().191 */192export function setupClipboardClearAlarm(): void {193  chrome.alarms.onAlarm.addListener((alarm) => {194    if (alarm.name !== CLIPBOARD_CLEAR_ALARM) return;195    // PCH-01-021: only clear if a clear is still owed. CLEAR_HASH_KEY is set on196    // copy and removed once a clear succeeds; its absence means the fast-path197    // setTimeout (or a prior alarm) already cleared, so a cold worker woken by a198    // stale alarm can skip the offscreen-document churn.199    void (async () => {200      try {201        const stored = await chrome.storage.session.get(CLEAR_HASH_KEY);202        if (stored?.[CLEAR_HASH_KEY] === undefined) {203          // Nothing owed — drop the stale alarm and return.204          await chrome.alarms.clear(CLIPBOARD_CLEAR_ALARM).catch(() => false);205          return;206        }207      } catch {208        // session.get failed — fall through and clear anyway. Erring toward209        // clearing keeps the secret-must-not-persist invariant.210      }211      await clearClipboardNow();212    })();213  });214}