messages.ts

82.6 KB · raw

1/**2 * messages.ts — the only background message handler.3 *4 * All `chrome.runtime.onMessage` handling for the service worker lives here.5 * Do not handle messages inline in entry.ts, alarms.ts, or any other6 * background file. Every background-bound MessageType in shared/types.ts7 * must have a case below. Content-script-bound message types (PING,8 * SUMMON_AUTOFILL, VAULT_LOCKED) are documented in shared/types.ts and9 * intentionally have no case here — they are sent *to* content scripts.10 */1112import { copyToClipboard, getEffectiveClearSeconds } from "./clipboard";13import { getPendingSyncAlerts, clearPendingSyncAlerts } from "./sync-alerts";14import { extractDomain } from "../shared/domain";15import {16  getPendingVouchedDevice,17  listPendingVouchedDevices,18  removePendingVouchedDevice,19} from "./pending-vouched-devices";20import {21  clearRecoveryConfirmPending,22  isRecoveryConfirmPending,23  setRecoveryConfirmPending,24} from "./recovery-pending";25import { setupAutoLock, touchAutoLock } from "./alarms";26import { runSelfTest } from "./self-test";27import * as vault from "./vault";28import {29  handleEstimateCodexPages,30  handleGetCodexVaultData,31  handleRestoreFromCodex,32} from "./codex-handlers";33import type { CodexRestorePayload } from "../shared/codex-types";34import {35  activateLicense,36  deactivateLicense,37  getLicenseInfo,38  getLicenseKey,39  isProUser,40  markCelebrationPlayed,41  shouldPlayCelebration,42} from "./license";43import {44  completeFirefoxPasskeyCreate,45  getPasskeyProviderState,46  getPendingPasskeyRequest,47  handleFirefoxPasskeyCreate,48  handleFirefoxPasskeyGet,49  handlePasskeyHasCredentials,50  initiateFirefoxPasskeyCreate,51  isPasskeyOfferDeclined,52  onVaultUnlocked,53  recordPasskeyOfferDecline,54  resolvePendingPasskeyRequest,55  setPasskeyProviderEnabled,56} from "./passkey";57import { buildAdvancedHealthReport } from "./health";58import { storageClear } from "../shared/storage";59import { findInvalidWords } from "../shared/recovery-words";60import type {61  Message,62  MessageResponse,63  MessageType,64  ProFeature,65  VaultEntry,66} from "../shared/types";67import { isPasskeyOnly } from "../shared/types";68import { generateTOTP } from "../crypto/totp";69import { isValidRpId } from "../shared/passkey-utils";70import { isValidMLFeatureBatch, predictBatch as mlPredictBatch } from "./ml";71import {72  buildLocalIdentity,73  getOrCreateDeviceId,74  setDeviceName as setDeviceNameV3,75} from "./device-identity";76import {77  isDeviceRevoked,78  listPairedDevices as listPairedDevicesV3,79  renamePairedDevice as renamePairedDeviceV3,80  revokeDeviceById as revokeDeviceByIdV3,81  upsertPairedDevice as upsertPairedDeviceV3,82} from "./paired-devices";83import { dropPairSecret } from "./pair-secrets";84// Sync and pairing are imported statically, never with `await import()`.85// `import()` throws `TypeError: import() is disallowed on86// ServiceWorkerGlobalScope` at runtime in MV3, and Vite's preload helper87// reports the failure through `window.dispatchEvent`, which does not exist in88// a worker either — so the real reason was replaced by "window is not defined"89// and the pairing UI just stopped. scripts/check-sw-static-imports.mjs fails90// the build if a runtime import() reappears anywhere in the worker's graph.91import {92  cancelRelayAdoptionSource,93  isRelayAdoptionSourceInFlight,94  resolveReceiverSasConfirmation,95  resolveSourceSasConfirmation,96  startRelayAdoptionReceiver,97  startRelayAdoptionSource,98} from "./relay-adoption-bg";99import {100  getSyncSettings,101  setRelayUrl,102  setSyncMode,103  type SyncMode,104} from "./sync-settings";105import {106  getSyncState,107  triggerSyncIfReachable,108} from "./sync-trigger";109import { isMessageSenderAllowed } from "./message-policy";110111const SUMMON_CAPABILITY_TTL_MS = 30_000;112type SummonCapability = { tabId: number; frameId: number; origin: string; operation: "inventory" | "fill"; expiresAt: number };113const summonCapabilities = new Map<string, SummonCapability>();114115export function createSummonCapability(tabId: number, origin: string, frameId = 0): string {116  const token = crypto.randomUUID();117  summonCapabilities.set(token, { tabId, frameId, origin, operation: "inventory", expiresAt: Date.now() + SUMMON_CAPABILITY_TTL_MS });118  return token;119}120121function consumeSummonCapability(token: unknown, sender: Partial<chrome.runtime.MessageSender>, operation: "inventory" | "fill"): boolean {122  if (typeof token !== "string") return false;123  const cap = summonCapabilities.get(token);124  if (!cap) return false;125  if (cap.expiresAt <= Date.now()) {126    summonCapabilities.delete(token);127    return false;128  }129  if (sender.tab?.id !== cap.tabId || (sender.frameId ?? 0) !== cap.frameId || attestedSenderOrigin(sender) !== cap.origin) return false;130  if (cap.operation !== operation) return false;131  if (operation === "inventory") cap.operation = "fill";132  else summonCapabilities.delete(token);133  return true;134}135136// Compile-time E2E flag, folded to a literal by Vite's `define` (false in137// every production build — verified in the bundled output). Mirrors the138// create-path bypass in passkey.ts so the Playwright passkey smoke can enable139// the provider without a real Pro licence; the gate is fully intact for users.140declare const __PEACH_E2E_PASSKEYS__: boolean;141const PASSKEY_E2E_PRO_BYPASS = __PEACH_E2E_PASSKEYS__ === true;142143function unhexBytes(hex: string): Uint8Array {144  if (hex.length % 2 !== 0) throw new Error("invalid hex");145  const out = new Uint8Array(hex.length / 2);146  for (let i = 0; i < out.length; i++) {147    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);148  }149  return out;150}151152/**153 * The single Pro gate. Every gated handler calls this first; the broadcast154 * lets the popup react (navigate to the License section, highlight the155 * relevant feature). Background never touches the popup view directly —156 * the popup owns navigation. See Projects.md "Pro gate rule".157 */158async function requirePro(159  feature: ProFeature,160  trigger: "user" | "background" = "user"161): Promise<{ allowed: boolean }> {162  if (PASSKEY_E2E_PRO_BYPASS && feature === "passkeys") return { allowed: true };163  if (await isProUser()) return { allowed: true };164165  if (trigger === "user") {166    // User deliberately triggered this feature — redirect the popup immediately.167    chrome.runtime168      .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })169      .catch(() => {170        // Popup may be closed — the gate signal is best-effort.171      });172  } else {173    // Background trigger (e.g. passive sync initiation) — no popup is open.174    // Queue a notice so the next popup open shows a soft banner instead of175    // silently failing.176    if (chrome.storage.session) {177      chrome.storage.session178        .set({ pendingProNotice: { feature, triggeredAt: Date.now() } })179        .catch(() => {});180    }181  }182183  return { allowed: false };184}185186// LV-3: minimum interval between non-test canary pings per webhook+entry.187const CANARY_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour188189const PRIVATE_IPV4_RANGES = [190  { prefix: "10." },191  { prefix: "127." },192  { prefix: "169.254." },193  { prefix: "192.168." },194];195196export function setupMessageHandler(): void {197  chrome.runtime.onMessage.addListener(198    (msg: unknown, sender, sendResponse) => {199      if (!isMessage(msg)) {200        sendResponse({ ok: false, error: "invalid_message" });201        return true;202      }203      handleMessage(msg, sender)204        .then(sendResponse)205        .catch((err) =>206          sendResponse({207            ok: false,208            error: err instanceof Error ? err.message : String(err),209          })210        );211      return true;212    }213  );214}215216function isMessage(m: unknown): m is Message {217  return typeof m === "object" && m !== null && "type" in m;218}219220/**221 * The browser-attested origin of the page that originated this message. A web222 * page cannot call chrome.runtime.sendMessage directly (no externally_connectable),223 * so every page-driven message arrives via the content-script relay and the224 * browser stamps `sender.origin`/`sender.url` with the real frame origin — a225 * value the page cannot forge. This is the ONLY trustworthy origin for passkey226 * rpId<->origin enforcement; payload-supplied origins are attacker-controlled.227 */228function attestedSenderOrigin(sender: Partial<chrome.runtime.MessageSender>): string | undefined {229  if (sender.origin) return sender.origin;230  if (sender.url) {231    try { return new URL(sender.url).origin; } catch { return undefined; }232  }233  return undefined;234}235236function isExtensionPageSender(sender: Partial<chrome.runtime.MessageSender>): boolean {237  return sender.url?.startsWith("chrome-extension://") === true ||238    sender.url?.startsWith("moz-extension://") === true;239}240241/** True when the message came from a page frame rather than extension UI. */242function isPageSender(sender: Partial<chrome.runtime.MessageSender>): boolean {243  return !isExtensionPageSender(sender) &&244    (sender.tab !== undefined || sender.url !== undefined);245}246247function entryDomains(url: unknown, additionalUrls: unknown): Set<string> {248  const domains = new Set<string>();249  const primary = typeof url === "string" ? extractDomain(url) : null;250  if (primary) domains.add(primary);251  if (Array.isArray(additionalUrls)) {252    for (const extra of additionalUrls) {253      if (typeof extra !== "string") continue;254      const domain = extractDomain(extra);255      if (domain) domains.add(domain);256    }257  }258  return domains;259}260261/**262 * PB-26-106: the autofill read path re-validates the frame origin against the263 * entry's domains on every request, so an unbound write path would let a264 * content script repoint an entry at its own origin and then read the265 * credential through a read path that is behaving correctly.266 *267 * Creating an entry: a page may only bind it to itself. Mutating one: a page268 * may never GROW the domain set — only shrink it, and never below its own269 * attested origin.270 *271 * Self-binding on mutation was considered and is not enough: the content272 * script on a foreign origin IS the attacker in the reported chain, so letting273 * it attach "its own" origin lets it mint the very capability the read path274 * exists to withhold. The legitimate flow that needs a new domain — the inline275 * "remember this site" toast — therefore asks the popup instead276 * (REQUEST_REMEMBER_SITE), which is an extension page and exempt here.277 *278 * Extension pages are exempt: the popup edits entries for sites it is not on.279 */280function senderMayBindEntryDomains(281  sender: Partial<chrome.runtime.MessageSender>,282  domains: Set<string>,283): boolean {284  if (isExtensionPageSender(sender)) return true;285  const domain = attestedSenderDomain(sender);286  if (domain === null) return false;287  for (const candidate of domains) {288    if (candidate !== domain) return false;289  }290  return true;291}292293function senderMayMutateEntryDomains(294  sender: Partial<chrome.runtime.MessageSender>,295  before: Set<string>,296  after: Set<string>,297): boolean {298  if (isExtensionPageSender(sender)) return true;299  const domain = attestedSenderDomain(sender);300  if (domain === null || !after.has(domain)) return false;301  for (const kept of after) {302    if (!before.has(kept)) return false;303  }304  return true;305}306307/**308 * Null for extension pages (which the callers exempt), for senders with no309 * attested origin at all, and for unparseable frame urls.310 */311function attestedSenderDomain(sender: Partial<chrome.runtime.MessageSender>): string | null {312  if (sender.tab === undefined && sender.url === undefined) return null;313  const origin = attestedSenderOrigin(sender);314  if (!origin) return null;315  try { return extractDomain(new URL(origin).hostname); } catch { return null; }316}317318function patchChangesEntryOrigin(patch: object): boolean {319  return Object.hasOwn(patch, "url") || Object.hasOwn(patch, "additionalUrls");320}321322/** The entry's domain-bearing fields as the patch would leave them. */323function patchedUrlShape(324  patch: object,325  existing: Pick<VaultEntry, "url" | "additionalUrls">,326): { url: unknown; additionalUrls: unknown } {327  const candidate = patch as { url?: unknown; additionalUrls?: unknown };328  return {329    url: Object.hasOwn(patch, "url") ? candidate.url : existing.url,330    additionalUrls: Object.hasOwn(patch, "additionalUrls")331      ? candidate.additionalUrls332      : existing.additionalUrls,333  };334}335// Resolves once restoreFromSession() has run, so handlers see the correct336// post-restore unlock state. entry.ts registers this BEFORE the message337// handler so the onMessage listener is added synchronously at SW startup338// (MV3 requirement) while unlock-dependent handlers still wait for restore.339let restoreReady: Promise<void> = Promise.resolve();340const MAX_ML_BATCH_FIELDS = 128;341const MAX_ML_FIELDS_PER_SECOND = 256;342const mlWorkBySender = new Map<string, { startedAt: number; fields: number }>();343344function claimMLWork(sender: Partial<chrome.runtime.MessageSender>, fields: number): boolean {345  if (fields === 0) return true;346  const now = Date.now();347  for (const [key, value] of mlWorkBySender) {348    if (now - value.startedAt >= 1_000) mlWorkBySender.delete(key);349  }350  const key = `${sender.tab?.id ?? "ui"}:${sender.frameId ?? 0}:${attestedSenderOrigin(sender) ?? "extension"}`;351  const current = mlWorkBySender.get(key);352  if (!current) {353    mlWorkBySender.set(key, { startedAt: now, fields });354    return fields <= MAX_ML_FIELDS_PER_SECOND;355  }356  if (current.fields + fields > MAX_ML_FIELDS_PER_SECOND) return false;357  current.fields += fields;358  return true;359}360361/** Called by entry.ts at startup with the in-flight restoreFromSession promise. */362export function setRestoreReady(p: Promise<unknown>): void {363  restoreReady = p.then(() => undefined, () => undefined);364}365366export async function handleMessage(367  msg: Message,368  sender: Partial<chrome.runtime.MessageSender> = {}369): Promise<MessageResponse> {370  try {371    // Wait for session-restore to finish before reading any unlock/vault state.372    // On a cold SW start the popup's first message arrives while restore is373    // still in flight; without this gate IS_UNLOCKED would report locked (a374    // spurious lock screen) and the storage reads could race. The onMessage375    // listener itself is registered synchronously (entry.ts) so this gate, not376    // listener registration, is what absorbs the cold-start window.377    await restoreReady;378    // Validate sender — only allow messages from our extension pages/scripts.379    // `sender.id` is undefined for messages originating from extension UI380    // (popup/options), which is legitimate; reject only when it is defined381    // and does not match our extension id.382    if (383      sender.id !== undefined &&384      sender.id !== chrome.runtime.id385    ) {386      return { ok: false, error: "invalid_sender" };387    }388389    // PB-26-016: sender authorization is an exhaustive policy, not a390    // hand-maintained privileged denylist. A missing/unknown policy entry or a391    // background-only message fails closed before any handler runs.392    // Extension UI messages may legitimately omit sender.url. Resolve the393    // browser-attested extension origin only when a URL is present; this also394    // keeps test/runtime shims without getURL fail-closed for URL-bearing395    // senders instead of throwing before policy evaluation.396    const extensionOrigin = sender.url && typeof chrome.runtime.getURL === "function"397      ? chrome.runtime.getURL("")398      : "";399    if (!isMessageSenderAllowed(msg.type, sender, chrome.runtime.id, extensionOrigin)) {400      return { ok: false, error: "invalid_sender" };401    }402403    if (shouldRefreshAutoLock(msg.type, sender) && vault.isUnlocked()) {404      touchAutoLock();405    }406407    switch (msg.type) {408      case "CREATE_VAULT": {409        const { password } = msg.payload as { password: string };410        // Refuse to create a vault under an empty master password — the derived411        // key would be publicly known and the at-rest AES-GCM protection void412        // (audit HIGH; the CSV-import path previously passed ""). Length/strength413        // policy lives in the onboarding UI; this is the non-negotiable floor.414        if (typeof password !== "string" || password.length === 0) {415          return { ok: false, error: "invalid_payload" };416        }417        await vault.createVault(password);418        await setupAutoLock();419        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });420        onVaultUnlocked();421        return { ok: true };422      }423      case "CREATE_VAULT_FROM_ROOT_KEY": {424        const { password, rootKeyHex } = (msg.payload ?? {}) as {425          password?: unknown;426          rootKeyHex?: unknown;427        };428        if (typeof password !== "string" || password.length === 0 || typeof rootKeyHex !== "string") {429          return { ok: false, error: "invalid_payload" };430        }431432        try {433          const { vaultId } = await vault.createVaultFromRootKey(password, rootKeyHex);434          await setupAutoLock();435          broadcastToContentScripts({ type: "VAULT_UNLOCKED" });436          onVaultUnlocked();437          return { ok: true, vaultId };438        } catch (err) {439          return { ok: false, error: err instanceof Error ? err.message : String(err) };440        }441      }442      case "UNLOCK": {443        const { password } = msg.payload as { password: string };444        const ok = await vault.unlock(password);445        if (ok) {446          // KAT: verify the WebCrypto stack is healthy before the user relies on it.447          // Runs after unlock so latency is hidden behind the vault-load time.448          const kat = await runSelfTest();449          if (!kat.ok) {450            console.error("[Peach] Crypto self-test FAILED:", kat.error);451            await vault.lock();452            return { ok: false, error: "self_test_failed" };453          }454          await vault.updateSettings({ pinPreferred: false });455          await setupAutoLock();456          broadcastToContentScripts({ type: "VAULT_UNLOCKED" });457          onVaultUnlocked();458          return { ok: true };459        }460        // Distinguish corruption from wrong password so the popup can offer461        // a history-restore path instead of falsely accusing the user of462        // mistyping (M7/COR-007).463        const failure = vault.consumeLastUnlockFailureReason();464        if (failure) {465          return {466            ok: false,467            error: "vault_corrupted",468            canRestoreFromHistory: true,469          } as MessageResponse;470        }471        return { ok: false };472      }473      case "UNLOCK_WITH_PIN": {474        const { pin } = (msg.payload ?? {}) as { pin?: unknown };475        if (typeof pin !== "string") return { ok: false, error: "invalid_payload" };476        const ok = await vault.unlockWithPin(pin);477        if (!ok) {478          const status = await vault.getPinUnlockStatus();479          if (status.configured && !status.available) {480            return {481              ok: false,482              error: status.attemptsRemaining === 0483                ? "pin_attempts_exceeded"484                : "pin_session_expired",485            };486          }487          return { ok: false };488        }489        const kat = await runSelfTest();490        if (!kat.ok) {491          await vault.lock();492          return { ok: false, error: "self_test_failed" };493        }494        await vault.updateSettings({ pinPreferred: true });495        await setupAutoLock();496        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });497        onVaultUnlocked();498        return { ok: true };499      }500      case "GET_PIN_STATUS":501        return { ok: true, data: await vault.getPinUnlockStatus() };502      case "VALIDATE_RECOVERY_WORDS": {503        const { words } = msg.payload as { words: string[] };504        const invalid = findInvalidWords(words);505        return { ok: true, data: { valid: invalid.length === 0, invalid } };506      }507      case "GET_RECOVERY_WORDS": {508        // Must be unlocked and have recovery set up (vault must be v2.12+).509        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };510        // LV-4: the recovery words re-derive the ROOT KEY — the highest-511        // value secret in the vault. A session unlock (which may be hours512        // old on a shared machine) is not enough; require a fresh password513        // proof, matching the 1Password/Bitwarden pattern for revealing514        // escrow material. EXEMPTION: the create-vault / rotation confirmation515        // window (peach_recovery_pending) shows the words immediately after516        // the user authenticated, and that confirmation UI has no password to517        // resend — skip the re-auth there only.518        if (!(await isRecoveryConfirmPending())) {519          const { password } = (msg.payload ?? {}) as { password?: string };520          if (typeof password !== "string" || !password) {521            return { ok: false, error: "password_required" };522          }523          if (!(await vault.verifyMainPassword(password))) {524            return { ok: false, error: "wrong_password" };525          }526        }527        try {528          const words = await vault.getRecoveryWords();529          return { ok: true, data: words };530        } catch {531          return { ok: false, error: "recovery_not_setup" };532        }533      }534      case "GET_RECOVERY_CONFIRM_PENDING":535        return { ok: true, data: await isRecoveryConfirmPending() };536      case "SET_RECOVERY_CONFIRM_PENDING":537        await setRecoveryConfirmPending();538        return { ok: true };539      case "CLEAR_RECOVERY_CONFIRM_PENDING":540        await clearRecoveryConfirmPending();541        return { ok: true };542      case "RECOVER_WITH_WORDS": {543        // User has lost their password but has their 12 recovery words.544        // We: (1) validate the words, (2) re-encrypt vault with new password.545        const { words, newPassword } = msg.payload as {546          words: string[];547          newPassword: string;548        };549        const stored = await vault.loadSlotA();550        if (!stored) return { ok: false, error: "no_vault" };551        if (!stored.recovery_salt || !stored.recovery_wrapped_root_key) {552          return { ok: false, error: "recovery_not_setup" };553        }554        // Unwrap the root key from the recovery escrow.555        try {556          await vault.unwrapRootKeyForRecovery(557            stored.recovery_wrapped_root_key,558            stored.recovery_nonce ?? "",559            stored.recovery_tag ?? "",560            stored.recovery_salt,561            words562          );563        } catch {564          return { ok: false, error: "invalid_recovery_words" };565        }566        // Re-key: derive fresh salt + key from new password.567        const newSalt = vault.generateSalt();568        const newKey = await vault.deriveKey(newPassword, newSalt);569        // Re-wrap root key under the new password's derived key so the same570        // recovery words continue to work after the password change.571        await vault.rewrapRootKeyForPassword(newKey);572        // Re-encrypt vault entries with the new key.573        await vault.changePasswordWithNewKey(newSalt, newKey);574        await setupAutoLock();575        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });576        return { ok: true };577      }578      case "LOCK":579        await vault.lock();580        return { ok: true };581      case "IS_UNLOCKED":582        return { ok: true, data: vault.isUnlocked() };583      case "GET_VAULT_STATUS":584        return { ok: true, unlocked: vault.isUnlocked() };585      case "HAS_VAULT":586        return { ok: true, data: await vault.hasVault() };587      case "GET_ENTRIES":588        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };589        return { ok: true, data: vault.getEntries() };590      case "EXPORT_ENTRIES": {591        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };592        const { password } = (msg.payload ?? {}) as { password?: unknown };593        if (typeof password !== "string" || !password) {594          return { ok: false, error: "password_required" };595        }596        if (!(await vault.verifyMainPassword(password))) {597          return { ok: false, error: "wrong_password" };598        }599        return { ok: true, data: vault.getEntries() };600      }601      case "ADD_ENTRY": {602        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };603        const payload = msg.payload;604        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {605          return { ok: false, error: "invalid_payload" };606        }607        // PB-26-106: a page may only create an entry bound to itself. Entries608        // carrying no url have no domain surface to bind, so they are exempt.609        if (isPageSender(sender)) {610          const draft = payload as { url?: unknown; additionalUrls?: unknown };611          const domains = entryDomains(draft.url, draft.additionalUrls);612          if (domains.size > 0 && !senderMayBindEntryDomains(sender, domains)) {613            return { ok: false, error: "origin_binding_required" };614          }615        }616        const entry = await vault.addEntry(payload as Parameters<typeof vault.addEntry>[0]);617        return { ok: true, data: entry };618      }619      case "BATCH_ADD_ENTRIES": {620        const payload = msg.payload as { entries?: unknown } | null | undefined;621        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {622          return { ok: false, error: "invalid_payload" };623        }624        if (!Array.isArray(payload.entries)) {625          return { ok: false, error: "invalid_payload" };626        }627        const added = await vault.batchAddEntries(payload.entries as Parameters<typeof vault.batchAddEntries>[0]);628        return { ok: true, data: added };629      }630      case "RESTORE_ENTRIES": {631        const payload = msg.payload as632          | { entries?: unknown; mode?: unknown; settings?: unknown }633          | null634          | undefined;635        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {636          return { ok: false, error: "invalid_payload" };637        }638        if (!Array.isArray(payload.entries)) {639          return { ok: false, error: "invalid_payload" };640        }641        if (payload.mode !== "replace" && payload.mode !== "merge") {642          return { ok: false, error: "invalid_payload" };643        }644        const summary = await vault.restoreEntries(645          payload.entries as Parameters<typeof vault.restoreEntries>[0],646          payload.mode647        );648        if (payload.settings && typeof payload.settings === "object" && !Array.isArray(payload.settings)) {649          await vault.updateSettings(payload.settings as vault.VaultSettings);650        }651        return { ok: true, data: summary };652      }653      case "CLEAR_VAULT_ENTRIES": {654        await vault.clearVaultEntries("REPLACE_ALL");655        return { ok: true };656      }657      case "GET_VAULT_HISTORY": {658        const snapshots = await vault.getVaultHistory();659        return { ok: true, data: snapshots };660      }661      case "RESTORE_VAULT_SNAPSHOT": {662        const { snapshotId } = msg.payload as { snapshotId: string };663        const result = await vault.restoreVaultSnapshot(snapshotId);664        if (!result.ok) return { ok: false, error: result.error };665        broadcastToContentScripts({ type: "VAULT_LOCKED" });666        return { ok: true };667      }668      case "COUNT_DUPLICATES": {669        return { ok: true, data: vault.countDuplicates() };670      }671      case "DEDUPLICATE_ENTRIES": {672        const removed = await vault.deduplicateEntries();673        return { ok: true, data: removed };674      }675      case "UPDATE_ENTRY": {676        const payload = msg.payload as { id?: unknown; patch?: unknown } | null | undefined;677        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {678          return { ok: false, error: "invalid_payload" };679        }680        const { id, patch } = payload;681        if (typeof id !== "string" || !id || !patch || typeof patch !== "object" || Array.isArray(patch)) {682          return { ok: false, error: "invalid_payload" };683        }684        const existingEntry = vault.getEntryById(id);685        if (!existingEntry) return { ok: false, error: "entry_not_found" };686        if (patchChangesEntryOrigin(patch) || isPageSender(sender)) {687          const before = entryDomains(existingEntry.url, existingEntry.additionalUrls);688          const after = patchedUrlShape(patch, existingEntry);689          if (!senderMayMutateEntryDomains(690            sender,691            before,692            entryDomains(after.url, after.additionalUrls),693          )) {694            return { ok: false, error: "origin_binding_required" };695          }696        }697        // Single enforcement point for the Canary Entries Pro gate. Only698        // gates the transition into a canary entry — clearing/disabling a699        // canary (`canary: undefined`) is always allowed, so existing700        // canary entries remain manageable.701        const incomingCanary = (patch as { canary?: unknown }).canary;702        if (incomingCanary && typeof incomingCanary === "object") {703          const existing = vault.getEntryById(id);704          if (!existing?.canary) {705            const gate = await requirePro("canary_entry");706            if (!gate.allowed) return { ok: false, error: "pro_required" };707          }708        }709        await vault.updateEntry(id, patch as Parameters<typeof vault.updateEntry>[1]);710        return { ok: true };711      }712      case "ADD_ADDITIONAL_URL": {713        const { id, url } = msg.payload as { id: string; url: string };714        const trimmed = typeof url === "string" ? url.trim() : "";715        if (!trimmed) return { ok: false, error: "url_required" };716        const existing = vault.getEntryById(id);717        if (!existing) return { ok: false, error: "entry_not_found" };718        const merged = [...(existing.additionalUrls ?? []), trimmed];719        if (!senderMayMutateEntryDomains(720          sender,721          entryDomains(existing.url, existing.additionalUrls),722          entryDomains(existing.url, merged),723        )) {724          return { ok: false, error: "origin_binding_required" };725        }726        if (existing.type !== "login") return { ok: false, error: "login_entries_only" };727        await vault.updateEntry(id, { additionalUrls: merged });728        return { ok: true };729      }730      case "REQUEST_REMEMBER_SITE": {731        // PB-26-106: growing an entry's domain set needs a decision the page732        // cannot make for itself, so the page asks and the popup confirms. A733        // page may only ask about its own attested origin — otherwise this734        // becomes the same unbound write under a friendlier name.735        const { id, url } = (msg.payload ?? {}) as { id?: unknown; url?: unknown };736        if (typeof id !== "string" || !id) return { ok: false, error: "invalid_payload" };737        const trimmed = typeof url === "string" ? url.trim() : "";738        if (!trimmed) return { ok: false, error: "url_required" };739        if (!senderMayBindEntryDomains(sender, entryDomains(trimmed, undefined))) {740          return { ok: false, error: "origin_binding_required" };741        }742        if (!vault.getEntryById(id)) return { ok: false, error: "entry_not_found" };743        if (chrome.storage.session) {744          await chrome.storage.session.set({ pendingRememberSite: { entryId: id, url: trimmed } });745        }746        try { await chrome.action.openPopup(); } catch { /* user opens via toolbar */ }747        return { ok: true };748      }749      case "DELETE_ENTRY": {750        const { id } = msg.payload as { id: string };751        await vault.deleteEntry(id);752        return { ok: true };753      }754      case "GET_DELETED_ENTRIES":755        return { ok: true, data: vault.getDeletedEntries() };756      case "RESTORE_ENTRY": {757        const { id } = msg.payload as { id: string };758        await vault.restoreEntry(id);759        return { ok: true };760      }761      case "PURGE_ENTRY": {762        const { id } = msg.payload as { id: string };763        await vault.purgeEntry(id);764        return { ok: true };765      }766      case "PURGE_EXPIRED_TRASH": {767        const { retentionMs } = msg.payload as { retentionMs: number };768        const purged = await vault.purgeExpiredTrash(retentionMs);769        return { ok: true, data: purged };770      }771      case "SAVE_NOTE": {772        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };773        const { id, title, body } = msg.payload as { id?: string; title: string; body: string };774        const now = Date.now();775        if (id) {776          const existing = vault.getEntryById(id);777          if (!existing) return { ok: false, error: "not_found" };778          await vault.updateEntry(id, { title: title || "Untitled", notes: body, modifiedAt: now });779          return { ok: true, data: vault.getEntryById(id) };780        } else {781          // PCH-01-026 #10: addEntry stamps its own createdAt/modifiedAt782          // (EntryDraft omits both), so passing them was the only reason the783          // `as any` cast existed. Dropping them makes the call type-check.784          const entry = await vault.addEntry({785            type: "note",786            title: title || "Untitled",787            notes: body,788          });789          return { ok: true, data: entry };790        }791      }792      case "DELETE_NOTE": {793        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };794        const { id } = msg.payload as { id: string };795        await vault.deleteEntry(id);796        return { ok: true };797      }798      case "GET_NOTES": {799        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };800        const notes = vault.getEntries().filter(801          (e) => e.type === "note" && !e.deletedAt802        );803        return { ok: true, data: notes } as MessageResponse;804      }805      case "SEARCH_VAULT": {806        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };807        // AUD-002: shape-validate the payload before calling string methods.808        // A raw cast lets a malformed message crash the handler with TypeError.809        const { query } = (msg.payload ?? {}) as { query?: unknown };810        if (typeof query !== "string") {811          return { ok: false, error: "invalid_payload" };812        }813        const q = query.toLowerCase();814        const searchResults = vault.getEntries().filter(815          (e) =>816            !e.deletedAt &&817            (e.title.toLowerCase().includes(q) ||818              e.notes?.toLowerCase().includes(q) ||819              e.username?.toLowerCase().includes(q))820        );821        return { ok: true, data: searchResults } as MessageResponse;822      }823      case "AUTOFILL_QUERY": {824        if (!vault.isUnlocked()) {825          return { ok: false, error: "vault_locked" };826        }827        const { domain, kind, capability } = msg.payload as {828          domain: string;829          kind?: "login" | "card" | "totp";830          capability?: string;831        };832        const includeAll = consumeSummonCapability(capability, sender, "inventory");833        if (capability !== undefined && !includeAll) {834          return { ok: false, error: "entry_not_found" };835        }836        // kind="card": return saved cards as compact summaries for the837        // inline card silver platter. Cards are not domain-scoped — every838        // saved card is a candidate on every checkout page — so domain is839        // ignored when kind === "card".840        if (kind === "card") {841          if (!includeAll) return { ok: true, data: [] };842          const cards = vault.getEntries().filter((e) => e.type === "card");843          return {844            ok: true,845            data: cards.map((e) => ({846              id: e.id,847              title: e.cardNickname || e.title,848              cardholderName: e.cardholderName,849              last4: e.last4,850              expMonth: e.expMonth,851              expYear: e.expYear,852              network: e.network,853            })),854          };855        }856        const scopedDomain = isPageSender(sender)857          ? attestedSenderDomain(sender)858          : typeof domain === "string"859            ? extractDomain(domain)860            : null;861        if (!includeAll && !scopedDomain) {862          return isPageSender(sender)863            ? { ok: false, error: "origin_unverified" }864            : { ok: true, data: [] };865        }866        if (kind === "totp") {867          const matches = (868            includeAll869              ? vault.getEntries().filter((entry) => entry.type !== "note" && entry.type !== "card")870              : vault.queryDomain(scopedDomain!)871          ).filter((entry) => !isPasskeyOnly(entry) && Boolean(entry.totp));872          return {873            ok: true,874            data: matches.map((e) => ({875              id: e.id,876              title: e.title,877              username: e.username,878              email: e.email,879              url: e.url,880              hasTotp: true,881            })),882          };883        }884        // The inline selector fills username+password into a form. Passkey-885        // only entries have no password to fill; sign-in for those goes886        // through the WebAuthn path instead. Filter them out everywhere so887        // the user doesn't see four "GitHub" rows that mostly do nothing.888        // Notes and cards never belonged here either.889        const matches = (890          includeAll891            ? vault892                .getEntries()893                .filter((entry) => entry.type !== "note" && entry.type !== "card")894            : vault.queryDomain(scopedDomain!)895        ).filter((entry) => !isPasskeyOnly(entry));896        return {897          ok: true,898          data: matches.map((e) =>899            includeAll900              ? {901                  id: e.id,902                  title: e.title,903                  // username/email/url shipped so the inline popup's search904                  // affordance can match on them, not just titles. No secrets905                  // cross the boundary — same shape as the domain-matched906                  // response below minus hasTotp (which the search list907                  // doesn't surface).908                  username: e.username,909                  email: e.email,910                  url: e.url,911                  hasPasskey: e.passkey !== undefined,912                }913              : {914                  id: e.id,915                  title: e.title,916                  username: e.username,917                  email: e.email,918                  url: e.url,919                  hasTotp: Boolean(e.totp),920                  hasPasskey: e.passkey !== undefined,921                }922          ),923        };924      }925      case "GET_ENTRY_FOR_FILL": {926        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };927        const { id, capability } = msg.payload as {928          id: string;929          capability?: string;930        };931        const capabilityAuthorized = capability !== undefined && consumeSummonCapability(capability, sender, "fill");932        const senderDomain = isPageSender(sender) ? attestedSenderDomain(sender) : null;933        if (isPageSender(sender) && !senderDomain) {934          return { ok: false, error: "origin_unverified" };935        }936        const entry = vault.getEntryById(id);937        if (!entry) return { ok: false, error: "entry_not_found" };938939        // MV-1: re-validate the entry against the requesting frame's origin.940        // sender.url / sender.tab.url are browser-attested — a page cannot941        // forge them. PB-26-005: the FRAME url (sender.url) is authoritative942        // for an all_frames content script; the top-frame tab.url would943        // misattribute a cross-origin iframe's request to the top page. When944        // the request does not come from a tab at all (popup surfaces use945        // GET_ENTRY_FOR_EDIT, not this), refuse.946        if (senderDomain && !capabilityAuthorized) {947          if (!entryDomains(entry.url, entry.additionalUrls).has(senderDomain)) {948            // Domain mismatch — refuse silently (no oracle about which ids exist).949            return { ok: false, error: "entry_not_found" };950          }951        }952        // Passkey-only entries (passkey credential, no password) cannot be953        // filled into a form — sign-in goes through the WebAuthn path. A954        // login that ALSO has a passkey is still fillable for its password.955        if (isPasskeyOnly(entry)) return { ok: false, error: "passkey_not_fillable" };956        // Strip the passkey credential AND the raw base32 TOTP seed from the957        // response. Content scripts never need either: the private key is the958        // authenticator's, and handing over the TOTP seed would let a959        // compromised content script mint valid codes indefinitely (SEV-3-C).960        // Generate the *current* 6-digit code here in the background and send961        // only that, plus the seconds left so the UI can render a countdown.962        // Also drop passwordHistory: filling needs only the *current* password.963        // Handing every past password to a content script during passive964        // autofill on an arbitrary page would widen the blast radius of a965        // compromised page far beyond the one credential being filled.966        const {967          passkey: _strippedPasskey,968          totp: seed,969          totpAlgorithm,970          totpDigits,971          totpPeriod,972          passwordHistory: _ph,973          ...safeEntry974        } = entry;975        let totpCode: string | undefined;976        let totpRemaining: number | undefined;977        if (seed) {978          try {979            // PCH-01-019: honour stored algorithm/digits/period (defaults apply980            // when these are undefined, i.e. RFC-default bare-seed entries).981            const generated = generateTOTP(seed, totpAlgorithm, totpDigits, totpPeriod);982            totpCode = generated.code;983            totpRemaining = generated.remaining;984          } catch {985            totpCode = undefined;986          }987        }988        if (isPageSender(sender) && vault.isUnlocked()) touchAutoLock();989        return { ok: true, data: { ...safeEntry, totpCode, totpRemaining } };990      }991      // Trusted full-entry fetch for extension-owned edit surfaces. Content992      // scripts route editing to the popup and cannot call this message.993      case "GET_ENTRY_FOR_EDIT": {994        if (isPageSender(sender)) return { ok: false, error: "invalid_sender" };995        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };996        const { id } = msg.payload as { id: string };997        const entry = vault.getEntryById(id);998        if (!entry) return { ok: false, error: "entry_not_found" };9991000        const { passkey: _passkey, ...safeEntry } = entry;1001        return { ok: true, data: safeEntry };1002      }1003      case "COPY": {1004        // AUD-002: shape-validate the payload before passing to the clipboard1005        // helper. A raw cast lets a malformed message reach the clipboard API1006        // with a non-string and throw.1007        const { text, label, alreadyWritten } = (msg.payload ?? {}) as {1008          text?: unknown;1009          label?: unknown;1010          alreadyWritten?: unknown;1011        };1012        if (typeof text !== "string" || typeof label !== "string") {1013          return { ok: false, error: "invalid_payload" };1014        }1015        await copyToClipboard(text, label, Boolean(alreadyWritten));1016        return { ok: true, clearSeconds: getEffectiveClearSeconds() };1017      }1018      case "CHECK_ALL_BREACHES": {1019        const allEntries = vault.getEntries();1020        const results: Record<string, number> = {};1021        for (const entry of allEntries) {1022          if (entry.type === "note") continue;1023          if (!entry.password) continue;1024          results[entry.id] = await vault.checkBreach(entry.password);1025          // Small delay — be a good citizen to the HIBP API1026          await new Promise((r) => setTimeout(r, 200));1027        }1028        // Persist any non-zero counts back to entries1029        for (const [id, count] of Object.entries(results)) {1030          const entry = vault.getEntryById(id);1031          if (entry && (entry.breach ?? 0) !== count) {1032            await vault.updateEntry(id, { breach: count > 0 ? count : undefined });1033          }1034        }1035        return { ok: true, data: results };1036      }1037      case "GET_SETTINGS": {1038        const settings = await vault.ensureSettingsLoaded();1039        if (!isPageSender(sender)) return { ok: true, data: settings };10401041        const safeSettings: Pick<vault.VaultSettings, "accentColor" | "theme"> & {1042          manualSummonDomains?: NonNullable<vault.VaultSettings["manualSummonDomains"]>;1043        } = {1044          accentColor: settings.accentColor,1045          theme: settings.theme,1046        };1047        const domain = attestedSenderDomain(sender);1048        const ownSummon = domain ? settings.manualSummonDomains?.[domain] : undefined;1049        if (domain && ownSummon) safeSettings.manualSummonDomains = { [domain]: ownSummon };1050        return { ok: true, data: safeSettings };1051      }1052      case "UPDATE_SETTINGS": {1053        const patch = msg.payload;1054        if (!patch || typeof patch !== "object" || Array.isArray(patch)) {1055          return { ok: false, error: "invalid_payload" };1056        }1057        await vault.updateSettings(patch as vault.VaultSettings);1058        if (typeof (patch as Record<string, unknown>).autoLockMinutes === "number") {1059          await setupAutoLock();1060          if (vault.isUnlocked()) touchAutoLock();1061        }1062        return { ok: true };1063      }1064      case "SETUP_PIN": {1065        const { currentPassword, pin } = (msg.payload ?? {}) as {1066          currentPassword?: unknown;1067          pin?: unknown;1068        };1069        if (typeof currentPassword !== "string" || typeof pin !== "string") {1070          return { ok: false, error: "invalid_payload" };1071        }1072        return (await vault.setupPinUnlock(currentPassword, pin))1073          ? { ok: true }1074          : { ok: false, error: "incorrect_current_password" };1075      }1076      case "DISABLE_PIN": {1077        const { currentPassword } = (msg.payload ?? {}) as { currentPassword?: unknown };1078        if (typeof currentPassword !== "string") return { ok: false, error: "invalid_payload" };1079        return (await vault.disablePinUnlock(currentPassword))1080          ? { ok: true }1081          : { ok: false, error: "incorrect_current_password" };1082      }1083      case "GET_PENDING_PASSKEY":1084        return { ok: true, data: await getPendingPasskeyRequest() };1085      case "APPROVE_PASSKEY": {1086        const { requestId } = msg.payload as { requestId: number };1087        await resolvePendingPasskeyRequest(requestId, true);1088        return { ok: true };1089      }1090      case "DENY_PASSKEY": {1091        const { requestId } = msg.payload as { requestId: number };1092        await resolvePendingPasskeyRequest(requestId, false);1093        return { ok: true };1094      }1095      case "GET_PASSKEY_PROVIDER_STATE":1096        return { ok: true, data: await getPasskeyProviderState() };1097      case "SET_PASSKEY_PROVIDER_ENABLED": {1098        const { enabled } = msg.payload as { enabled: boolean };1099        // Disabling is always free; gating fires only when a free user is1100        // trying to turn passkey interception on.1101        if (enabled) {1102          const gate = await requirePro("passkeys");1103          if (!gate.allowed) return { ok: false, error: "pro_required" };1104        }1105        return { ok: true, data: await setPasskeyProviderEnabled(enabled) };1106      }1107      // AUD-003: this handler POSTs `entryTitle` to a user-configured HTTPS1108      // endpoint. Intentional product behavior — users configure their own1109      // dead-man's-switch. isAllowedCanaryWebhookUrl blocks localhost, private1110      // IPs, and non-HTTPS schemes, but cannot defend against every DNS-1111      // rebinding case. The UI under CanarySection (Entry.tsx) discloses to1112      // users that the entry title leaves the device when the canary fires.1113      case "FIRE_CANARY_PING": {1114        // Canary alert fires unconditionally for any entry with canary: true.1115        // The Pro gate only governs creation/flagging of canary entries; once1116        // an entry is flagged, the alert path is a security property and must1117        // never be license-gated.1118        const { webhookUrl, entryTitle, trigger } = msg.payload as {1119          webhookUrl: string;1120          entryTitle: string;1121          trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";1122        };1123        if (trigger === "test" && !isExtensionPageSender(sender)) {1124          return { ok: false, error: "invalid_sender" };1125        }1126        if (!isAllowedCanaryWebhookUrl(webhookUrl)) {1127          return { ok: false, error: "invalid_webhook_url" };1128        }1129        // LV-3: debounce non-test pings per (webhook, entry) so a page that1130        // strobes focus/fill events can't turn the canary into a request1131        // firehose at the user's endpoint. Test pings are exempt — they are1132        // explicit user clicks in the settings UI and are awaited for1133        // feedback. The FIRST ping in a window always goes out, so the1134        // dead-man's-switch property (canary fires on access) is preserved;1135        // only repeats within the window are coalesced.1136        if (trigger !== "test") {1137          const keyDigest = new Uint8Array(1138            await crypto.subtle.digest(1139              "SHA-256",1140              new TextEncoder().encode(`${webhookUrl}:${entryTitle}`),1141            ),1142          );1143          const keySuffix = Array.from(keyDigest)1144            .map((byte) => byte.toString(16).padStart(2, "0"))1145            .join("");1146          const rlKey = `canary-last-fetch:${keySuffix}`;1147          const legacyRlKey = `canary-last-fetch:${webhookUrl}:${entryTitle}`;1148          const stored = await chrome.storage.local.get(rlKey);1149          await chrome.storage.local.remove(legacyRlKey);1150          const last = (stored as Record<string, unknown>)[rlKey];1151          if (typeof last === "number" && Date.now() - last < CANARY_MIN_INTERVAL_MS) {1152            return { ok: true, data: { rateLimited: true } };1153          }1154          await chrome.storage.local.set({ [rlKey]: Date.now() });1155        }1156        const body = JSON.stringify({1157          event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",1158          entry: entryTitle,1159          trigger,1160          timestamp: new Date().toISOString(),1161          source: "peach-extension",1162        });1163        if (trigger === "test") {1164          // Permitted outbound fetch: user-configured canary alert endpoint1165          // (validated by isAllowedCanaryWebhookUrl above). This is the1166          // only non-HIBP outbound call in the extension — see1167          // Projects.md §3.7. Test pings are awaited so the UI can show1168          // success/failure.1169          try {1170            const res = await fetch(webhookUrl, {1171              method: "POST",1172              headers: { "Content-Type": "application/json" },1173              body,1174            });1175            return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };1176          } catch (err) {1177            return { ok: false, error: err instanceof Error ? err.message : "network_error" };1178          }1179        }1180        // Non-test triggers are fire-and-forget1181        fetch(webhookUrl, {1182          method: "POST",1183          headers: { "Content-Type": "application/json" },1184          body,1185        }).catch(() => {});1186        return { ok: true };1187      }1188      case "ACTIVATE_LICENSE": {1189        const { key } = msg.payload as { key: string };1190        const licenseInfo = await activateLicense(key);1191        return { ok: true, data: licenseInfo };1192      }1193      case "GET_LICENSE": {1194        const licenseInfo = await getLicenseInfo();1195        return { ok: true, data: licenseInfo };1196      }1197      case "GET_LICENSE_KEY": {1198        const key = await getLicenseKey();1199        return { ok: true, data: key };1200      }1201      case "DEACTIVATE_LICENSE": {1202        await deactivateLicense();1203        return { ok: true };1204      }1205      case "SHOULD_PLAY_CELEBRATION": {1206        const should = await shouldPlayCelebration();1207        return { ok: true, data: should };1208      }1209      case "MARK_CELEBRATION_PLAYED": {1210        await markCelebrationPlayed();1211        return { ok: true };1212      }1213      case "GET_CODEX_VAULT_DATA": {1214        // Pro-gated. requirePro broadcasts PRO_GATE_TRIGGERED on miss so the1215        // popup navigates to the License section without further coordination.1216        // Also privileged-page-gated (PB-26-001), and recoveryWords inside the1217        // snapshot additionally require a fresh master-password proof.1218        const gate = await requirePro("codex");1219        if (!gate.allowed) return { ok: false, error: "pro_required" };1220        const { password } = (msg.payload ?? {}) as { password?: string };1221        return await handleGetCodexVaultData(password);1222      }1223      case "ESTIMATE_CODEX_PAGES": {1224        const gate = await requirePro("codex");1225        if (!gate.allowed) return { ok: false, error: "pro_required" };1226        return await handleEstimateCodexPages();1227      }1228      case "RESTORE_FROM_CODEX": {1229        // !! IMPORTANT: intentionally NOT Pro-gated. A free device must still1230        // be able to restore a vault that was printed by a Pro user.1231        // DO NOT add a Pro gate here.1232        const payload = msg.payload as CodexRestorePayload;1233        return await handleRestoreFromCodex(payload);1234      }1235      case "OPEN_PRO_GATE": {1236        // Content script requests: open popup and navigate to License section.1237        const { feature } = (msg.payload ?? {}) as { feature?: string };1238        if (chrome.storage.session) {1239          await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });1240        }1241        try { await chrome.action.openPopup(); } catch { /* badge fallback */ }1242        return { ok: true };1243      }1244      case "OPEN_POPUP": {1245        // Content script asks to open the trusted extension popup — used by the1246        // inline locked bubble so the master password is entered in the popup,1247        // never in page-hosted DOM (audit HIGH). Best-effort: openPopup needs a1248        // user gesture + Chrome 127+/Firefox; on failure the user clicks the1249        // toolbar icon. The badge already signals Peach needs attention.1250        //1251        // Set a session flag so the popup knows it was opened for inline unlock1252        // and should close itself once the vault is unlocked (returning the user1253        // to the inline autofill selector on the page).1254        const { entryId } = (msg.payload ?? {}) as { entryId?: unknown };1255        if (entryId !== undefined && (typeof entryId !== "string" || entryId.length === 0 || entryId.length > 64)) {1256          return { ok: false, error: "invalid_payload" };1257        }1258        if (chrome.storage.session) {1259          if (typeof entryId === "string") {1260            await chrome.storage.session.set({ peach_open_entry: entryId });1261          } else {1262            await chrome.storage.session.set({ peach_inline_unlock: true });1263          }1264        }1265        try { await chrome.action.openPopup(); } catch { /* user opens via toolbar */ }1266        return { ok: true };1267      }1268      case "OPEN_DETACHED_POPUP": {1269        // A window created by the action popup and then orphaned with1270        // window.close() is cascaded shut by Chrome, so the file-picker1271        // flows (OS dialogs that dismiss the popup on focus loss) ask the SW1272        // to open the standalone window instead — the SW has no window to be1273        // associated with. The query string is allowlisted so this can never1274        // open an arbitrary URL.1275        const { route } = (msg.payload ?? {}) as { route?: string };1276        const search = route === "peachscript"1277          ? "?restore=peachscript"1278          : route === "csv"1279            ? "?restore=csv"1280            : route === "codex-pdf"1281              ? "?codex=pdf"1282              : null;1283        if (!search) return { ok: false, error: "bad_route" };1284        try {1285          const popupHtml =1286            chrome.runtime.getManifest().action?.default_popup ?? "src/popup/index.html";1287          await chrome.windows.create({1288            url: chrome.runtime.getURL(`${popupHtml}${search}`),1289            type: "popup",1290            width: 320,1291            height: 550,1292          });1293          return { ok: true, data: { detached: true } };1294        } catch {1295          return { ok: false };1296        }1297      }1298      case "GET_IDENTITIES": {1299        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1300        return { ok: true, data: vault.getIdentities() };1301      }1302      case "TOUCH_AUTO_LOCK": {1303        // touchAutoLock() is already called by shouldRefreshAutoLock above;1304        // this case just gives the popup a typed message to keep the session alive.1305        return { ok: true };1306      }1307      case "GET_ADVANCED_HEALTH_REPORT": {1308        const gate = await requirePro("advanced_health");1309        if (!gate.allowed) return { ok: false, error: "pro_required" };1310        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1311        const report = await buildAdvancedHealthReport(vault.getEntries());1312        return { ok: true, data: report };1313      }1314      case "IS_DURESS_ENABLED": {1315        const enabled = await vault.isDuressEnabled();1316        return { ok: true, data: enabled };1317      }1318      case "SETUP_DURESS": {1319        // Single enforcement point for the Duress Vault Pro gate. Free users1320        // who somehow reach this handler are rejected here; the popup also1321        // pre-checks isPro() to avoid showing the setup form to free users,1322        // but server-side is the actual security boundary.1323        const gate = await requirePro("duress_vault");1324        if (!gate.allowed) return { ok: false, error: "pro_required" };1325        // MV3 service workers restart after inactivity, losing the in-memory1326        // derived key. Detect this before calling setupDuress so the popup1327        // can show a meaningful prompt to re-lock and re-unlock.1328        if (!vault.isUnlocked()) return { ok: false, error: "session_expired" };1329        const { duressPassword } = msg.payload as { duressPassword: string };1330        await vault.setupDuress(duressPassword);1331        return { ok: true };1332      }1333      case "DISABLE_DURESS": {1334        await vault.disableDuress();1335        return { ok: true };1336      }1337      case "VERIFY_MAIN_PASSWORD": {1338        const { password } = msg.payload as { password: string };1339        const ok = await vault.verifyMainPassword(password);1340        return { ok: true, data: ok };1341      }1342      case "CHANGE_PASSWORD": {1343        const { currentPassword, newPassword } = msg.payload as {1344          currentPassword: string;1345          newPassword: string;1346        };1347        const changed = await vault.changePassword(currentPassword, newPassword);1348        return changed1349          ? { ok: true }1350          : { ok: false, error: "incorrect_current_password" };1351      }1352      case "GET_SITE_PASSWORD_POLICY": {1353        const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });1354        if (!tab?.id) return { ok: true, data: null };1355        try {1356          const response = await chrome.tabs.sendMessage(tab.id, {1357            type: "GET_SITE_PASSWORD_POLICY",1358          });1359          return (response as MessageResponse) ?? { ok: true, data: null };1360        } catch {1361          return { ok: true, data: null };1362        }1363      }1364      case "RECORD_MANUAL_SUMMON": {1365        const { domain } = (msg.payload ?? {}) as { domain?: unknown };1366        const origin = attestedSenderOrigin(sender);1367        let senderDomain: string | null = null;1368        if (origin) {1369          try {1370            const hostname = new URL(origin).hostname;1371            senderDomain = extractDomain(hostname) ?? hostname;1372          } catch {1373            senderDomain = null;1374          }1375        }1376        if (typeof domain !== "string" || domain.length > 253 || domain !== senderDomain) {1377          return { ok: false, error: "invalid_domain" };1378        }1379        const settings = vault.getSettings();1380        const existing = settings.manualSummonDomains?.[domain];1381        const now = Date.now();1382        await vault.updateSettings({1383          manualSummonDomains: {1384            ...settings.manualSummonDomains,1385            [domain]: {1386              firstSummonedAt: existing?.firstSummonedAt ?? now,1387              summonCount: (existing?.summonCount ?? 0) + 1,1388              lastSummonedAt: now,1389            },1390          },1391        });1392        return { ok: true };1393      }1394      case "DECLINE_CARD_SAVE": {1395        // PB-26-108: the declined-card digest is keyed by root-key material, so1396        // a decline cannot be recorded while locked. Offers are not shown while1397        // locked either, so this is unreachable in practice.1398        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1399        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1400        await vault.declineCardSaveOffer(last4, expMonth, expYear);1401        return { ok: true };1402      }1403      case "IS_CARD_DECLINED": {1404        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1405        const declined = await vault.isCardDeclined(last4, expMonth, expYear);1406        return { ok: true, data: declined };1407      }1408      case "INITIATE_FIREFOX_PASSKEY_CREATE": {1409        const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {1410          rpId?: string;1411          rpName?: string;1412          userName?: string;1413          userDisplayName?: string;1414        };1415        if (!rpId || !userName) return { ok: false, error: "rpid_and_username_required" };1416        return await initiateFirefoxPasskeyCreate({1417          rpId,1418          rpName: rpName ?? rpId,1419          userName,1420          userDisplayName,1421        }, attestedSenderOrigin(sender));1422      }14231424      case "COMPLETE_FIREFOX_PASSKEY_CREATE": {1425        const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;1426        if (!payload) return { ok: false, error: "missing_payload" };1427        return await completeFirefoxPasskeyCreate(payload);1428      }14291430      case "PASSKEY_OFFER_DECLINE": {1431        const { domain } = (msg.payload ?? {}) as { domain?: string };1432        const origin = attestedSenderOrigin(sender);1433        if (!domain || !origin || !isValidRpId(domain, origin)) return { ok: false, error: "invalid_domain" };1434        await recordPasskeyOfferDecline(domain);1435        return { ok: true };1436      }14371438      case "GET_PASSKEY_OFFER_DECLINED": {1439        const { domain } = (msg.payload ?? {}) as { domain?: string };1440        const origin = attestedSenderOrigin(sender);1441        if (!domain || !origin || !isValidRpId(domain, origin)) return { ok: false, error: "invalid_domain" };1442        return { ok: true, data: await isPasskeyOfferDeclined(domain) };1443      }14441445      case "PASSKEY_HAS_CREDENTIALS": {1446        const payload = msg.payload as {1447          rpId?: string;1448          allowCredentials?: Array<{ id: string; type?: string }>;1449        } | undefined;1450        if (!payload?.rpId) return { ok: false, error: "rpid_required" };1451        return await handlePasskeyHasCredentials({1452          rpId: payload.rpId,1453          allowCredentials: payload.allowCredentials ?? [],1454        }, attestedSenderOrigin(sender));1455      }14561457      case "PASSKEY_CREATE": {1458        const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;1459        if (!payload?.rpId || !payload?.userName) {1460          return { ok: false, error: "rpid_and_username_required" };1461        }1462        return await handleFirefoxPasskeyCreate(payload, attestedSenderOrigin(sender));1463      }14641465      case "PASSKEY_GET": {1466        const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;1467        if (!payload?.rpId) return { ok: false, error: "rpid_required" };1468        return await handleFirefoxPasskeyGet(payload, attestedSenderOrigin(sender));1469      }14701471      case "PASSKEY_AUTH_RESPONSE": {1472        const { requestId, approved } = (msg.payload ?? {}) as {1473          requestId?: string | number;1474          approved?: boolean;1475        };1476        if (requestId === undefined || requestId === null || typeof approved !== 'boolean') {1477          return { ok: false, error: 'requestId and approved required' };1478        }1479        const pending = await getPendingPasskeyRequest();1480        const senderOrigin = attestedSenderOrigin(sender);1481        if (1482          (pending && pending.requestId !== Number(requestId)) ||1483          (pending?.tabId !== undefined && sender.tab?.id !== pending.tabId) ||1484          (pending?.origin !== undefined && senderOrigin !== pending.origin)1485        ) {1486          return { ok: false, error: "invalid_passkey_approval_sender" };1487        }1488        await resolvePendingPasskeyRequest(Number(requestId), approved);1489        return { ok: true };1490      }14911492      case "GET_PASSKEY_FOR_DOMAIN": {1493        const { domain } = (msg.payload ?? {}) as { domain?: string };1494        if (!domain) return { ok: false, error: "domain_required" };1495        // Scope to the caller's own origin: a page may only ask about passkeys1496        // for a domain it actually owns, so an arbitrary page cannot enumerate1497        // which sites the user holds passkeys for or read the username (audit MEDIUM).1498        const origin = attestedSenderOrigin(sender);1499        if (!origin || !isValidRpId(domain, origin)) return { ok: true, data: null };1500        if (!vault.isUnlocked()) return { ok: true, data: null };1501        const match = vault1502          .getEntries()1503          .find((entry) => entry.passkey?.rpId === domain);1504        if (!match || !match.passkey) return { ok: true, data: null };1505        // Slim shape — only the fields State 11 needs. Never expose private1506        // key material, even though this is locked behind isUnlocked.1507        // bannerShown is the per-domain "already surfaced" flag, kept in1508        // trusted background session storage so content scripts never touch1509        // chrome.storage.session directly (PB-26-203).1510        let bannerShown = false;1511        if (chrome.storage.session) {1512          const flag = await chrome.storage.session.get(`peach_state11_shown:${domain}`);1513          bannerShown = Boolean((flag as Record<string, unknown>)[`peach_state11_shown:${domain}`]);1514        }1515        return {1516          ok: true,1517          data: {1518            rpId: match.passkey.rpId,1519            userName: match.passkey.userName,1520            browser: match.passkey.browser,1521            bannerShown,1522          },1523        };1524      }15251526      case "MARK_PASSKEY_BANNER_SHOWN": {1527        const { domain } = (msg.payload ?? {}) as { domain?: string };1528        if (typeof domain !== "string" || !domain) return { ok: false, error: "domain_required" };1529        // Same origin binding as the read path: a page may only mark its own1530        // domain, and the flag reveals nothing either way.1531        const origin = attestedSenderOrigin(sender);1532        if (!origin || !isValidRpId(domain, origin)) return { ok: true };1533        if (chrome.storage.session) {1534          await chrome.storage.session.set({ [`peach_state11_shown:${domain}`]: Date.now() });1535        }1536        return { ok: true };1537      }15381539      case "DESTROY_VAULT": {1540        // Irreversible by design. Lock zeroes the derived key + in-memory entries;1541        // storage.local.clear() wipes every Peach key (slots, settings, license,1542        // onboarded flag, history, sync state, declined cards, recall, etc.);1543        // session.clear() wipes pending passkey requests + pro-notice queues.1544        // Caller (Panic.tsx) handles UI navigation back to onboarding.1545        //1546        // The Pro license is the one thing that must survive: the user paid for1547        // it, and destroying a vault (panic wipe, fresh start) is not a refund.1548        // Capture it before the clear and restore it after. peach_pro_celebrated1549        // rides along so the Pro celebration doesn't replay on the next unlock.1550        const preserved = await chrome.storage.local.get([1551          "peach_license",1552          "peach_pro_celebrated",1553        ]);1554        await vault.lock();1555        await storageClear();1556        await vault.clearVaultHistory();1557        const toRestore: Record<string, unknown> = {};1558        if (preserved.peach_license !== undefined) toRestore.peach_license = preserved.peach_license;1559        if (preserved.peach_pro_celebrated !== undefined) toRestore.peach_pro_celebrated = preserved.peach_pro_celebrated;1560        if (Object.keys(toRestore).length > 0) {1561          await chrome.storage.local.set(toRestore);1562        }1563        if (chrome.storage.session) {1564          await chrome.storage.session.clear();1565        }1566        await clearRecoveryConfirmPending();1567        return { ok: true };1568      }15691570      case "GET_IDENTITY_SUGGESTIONS": {1571        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1572        const entries = vault.getEntries();1573        const suggestions = buildIdentitySuggestions(entries);1574        return { ok: true, data: { suggestions } };1575      }1576      case "ML_INIT":1577        return { ok: true };15781579      case "ML_PREDICT_BATCH": {1580        const { featuresArray } = (msg.payload ?? {}) as { featuresArray?: unknown };1581        if (!isValidMLFeatureBatch(featuresArray, MAX_ML_BATCH_FIELDS)) {1582          return { ok: false, error: "invalid_payload" };1583        }1584        if (!claimMLWork(sender, featuresArray.length)) {1585          return { ok: false, error: "rate_limited" };1586        }1587        return { ok: true, data: mlPredictBatch(featuresArray) };1588      }15891590      // ── Vault sync (v3) ───────────────────────────────────────────────1591      // Returns the public-facing local identity needed by the popup to1592      // drive Phase-1 audio pairing. The private long-term key never1593      // leaves the background.1594      case "GET_LOCAL_IDENTITY": {1595        // Not Pro-gated: a free device may attempt to pair so it can RECEIVE a1596        // Pro license from an already-Pro peer (at least one device must be Pro).1597        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1598        // Identity derives from the stable root key, not the password-derived1599        // key, so pairing survives a master-password change (SEV-2-B).1600        const masterKeyHex = vault.getRootKeyHex();1601        if (!masterKeyHex) return { ok: false, error: "vault_locked" };1602        const masterKey = unhexBytes(masterKeyHex);1603        try {1604          const identity = await buildLocalIdentity(masterKey);1605          // If this device is Pro, advertise the signed license so the peer can1606          // activate it after SAS confirmation — pairing carries Pro across.1607          if (await isProUser()) {1608            const license = await getLicenseKey();1609            if (license) (identity as { license?: string }).license = license;1610          }1611          return { ok: true, data: identity };1612        } finally {1613          masterKey.fill(0);1614        }1615      }16161617      case "LIST_PAIRED_DEVICES": {1618        const devices = await listPairedDevicesV3();1619        return { ok: true, data: { devices } };1620      }16211622      case "RENAME_PAIRED_DEVICE": {1623        const { deviceId, name } = (msg.payload ?? {}) as { deviceId?: string; name?: string };1624        if (typeof deviceId !== "string" || !deviceId || typeof name !== "string") {1625          return { ok: false, error: "invalid_payload" };1626        }1627        await renamePairedDeviceV3(deviceId, name);1628        return { ok: true };1629      }16301631      case "UNPAIR_DEVICE": {1632        const { deviceId } = (msg.payload ?? {}) as { deviceId?: string };1633        if (typeof deviceId !== "string" || !deviceId) {1634          return { ok: false, error: "invalid_payload" };1635        }1636        // Removing a device REVOKES it rather than just dropping the local1637        // row: a plain removal is undone the moment any other peer vouches it1638        // back through a manifest, which is exactly what the relay makes easy.1639        await revokeDeviceByIdV3(deviceId, await getOrCreateDeviceId());1640        // PB-26-213: forget the secret now so no further dial can use it.1641        // Removals that happen while locked leave orphans for the next1642        // unlock's reconcile to prune.1643        const unpairRootHex = vault.getRootKeyHex();1644        if (unpairRootHex) {1645          const unpairRoot = unhexBytes(unpairRootHex);1646          try {1647            await dropPairSecret(deviceId, unpairRoot);1648          } finally {1649            unpairRoot.fill(0);1650          }1651        } else {1652          await dropPairSecret(deviceId, null);1653        }1654        // Sync against the remaining hosts — the unpaired device no longer1655        // matters but the next alarm tick is up to ten minutes away.1656        void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);1657        return { ok: true };1658      }16591660      case "RENAME_LOCAL_DEVICE": {1661        const { name } = (msg.payload ?? {}) as { name?: string };1662        if (typeof name !== "string") return { ok: false, error: "invalid_payload" };1663        await setDeviceNameV3(name);1664        return { ok: true };1665      }16661667      // ── Relay pairing ─────────────────────────────────────────────────────1668      // Not Pro-gated (a free device may pair to receive Pro/sync). The whole NK1669      // session runs in the background; the SAS and the terminal result reach1670      // the popup as ADOPTION_SOURCE_SAS / ADOPTION_SOURCE_RESULT pushes.1671      case "START_ADOPTION_SOURCE": {1672        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1673        if (isRelayAdoptionSourceInFlight()) {1674          return { ok: false, error: "adoption_source_in_progress" };1675        }1676        try {1677          return { ok: true, data: await startRelayAdoptionSource() };1678        } catch (error) {1679          return {1680            ok: false,1681            error: error instanceof Error ? error.message : "adoption_source_failed",1682          };1683        }1684      }16851686      // Onboarding receiver — empty extension shows a QR, phone scans and1687      // sends the vault. The popup supplies the local-only password here so1688      // the background can wrap the adopted root key at rest without ever1689      // asking the user to retype it.1690      case "START_ADOPTION_RECEIVER": {1691        if (isRelayAdoptionSourceInFlight()) {1692          return { ok: false, error: "adoption_receiver_in_progress" };1693        }1694        const password = (msg.payload as { password?: unknown } | undefined)?.password;1695        if (typeof password !== "string" || password.length === 0) {1696          return { ok: false, error: "invalid_payload" };1697        }1698        try {1699          return { ok: true, data: await startRelayAdoptionReceiver(password) };1700        } catch (error) {1701          return {1702            ok: false,1703            error: error instanceof Error ? error.message : "adoption_receiver_failed",1704          };1705        }1706      }17071708      // Source-side mirror of RECEIVER_SAS_DECISION: the popup's confirm/cancel1709      // on the source's SAS screen. The id round-trips the same way so a stale1710      // late response can't approve the wrong session.1711      case "RECEIVER_SAS_DECISION": {1712        const payload = msg.payload as { id?: unknown; sas?: unknown; ok?: unknown } | undefined;1713        if (typeof payload?.id !== "string" || typeof payload.sas !== "string" || typeof payload.ok !== "boolean") {1714          return { ok: false, error: "invalid_payload" };1715        }1716        return { ok: resolveReceiverSasConfirmation(payload.id, payload.sas, payload.ok) };1717      }17181719      case "SOURCE_SAS_DECISION": {1720        const payload = msg.payload as { id?: unknown; ok?: unknown } | undefined;1721        if (typeof payload?.id !== "string" || typeof payload?.ok !== "boolean") {1722          return { ok: false, error: "invalid_payload" };1723        }1724        return { ok: resolveSourceSasConfirmation(payload.id, payload.ok) };1725      }17261727      case "CANCEL_ADOPTION_SOURCE": {1728        cancelRelayAdoptionSource();1729        return { ok: true };1730      }17311732      case "TRIGGER_SYNC": {1733        const payload = (msg.payload ?? {}) as {1734          reason?: "vault_change" | "foreground" | "manual";1735          force?: boolean;1736        };1737        const reason = payload.reason ?? "foreground";17381739        void triggerSyncIfReachable({ reason, force: payload.force === true });1740        return { ok: true };1741      }17421743      case "GET_SYNC_STATE": {1744        return { ok: true, data: getSyncState() };1745      }17461747      case "GET_SYNC_SETTINGS": {1748        return { ok: true, data: await getSyncSettings() };1749      }17501751      case "SET_SYNC_SETTINGS": {1752        const payload = (msg.payload ?? {}) as {1753          mode?: unknown;1754          relayUrl?: unknown;1755        };1756        try {1757          // A custom relay URL implies private mode; Peach's own relay stays1758          // public. See setRelayUrl.1759          if (payload.relayUrl !== undefined) {1760            if (typeof payload.relayUrl !== "string") {1761              return { ok: false, error: "invalid_relay_url" };1762            }1763            return { ok: true, data: await setRelayUrl(payload.relayUrl) };1764          }1765          if (payload.mode !== undefined) {1766            if (payload.mode !== "public" && payload.mode !== "private" && payload.mode !== "off") {1767              return { ok: false, error: "invalid_sync_mode" };1768            }1769            return { ok: true, data: await setSyncMode(payload.mode as SyncMode) };1770          }1771          return { ok: true, data: await getSyncSettings() };1772        } catch (error) {1773          return {1774            ok: false,1775            error: error instanceof Error ? error.message : "invalid_relay_url",1776          };1777        }1778      }17791780      case "GET_SYNC_ALERTS": {1781        const { consume } = (msg.payload ?? {}) as { consume?: boolean };1782        const alerts = await getPendingSyncAlerts();1783        // The popup reads on open and acknowledges with consume:true after1784        // rendering, so a popup crash between read and render doesn't lose1785        // the alerts.1786        if (consume) await clearPendingSyncAlerts();1787        return { ok: true, data: alerts };1788      }17891790      case "GET_PENDING_VOUCHED_DEVICES": {1791        return { ok: true, data: await listPendingVouchedDevices() };1792      }1793      case "CONFIRM_VOUCHED_DEVICE": {1794        const { deviceId } = msg.payload as { deviceId: string };1795        const pending = await getPendingVouchedDevice(deviceId);1796        if (!pending) return { ok: false, error: "not_pending" };1797        // Last gate before a vouched device becomes a sync peer. A queue entry1798        // can predate the revocation that names it, so revocation is re-checked1799        // here rather than trusted to have swept the queue.1800        if (await isDeviceRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {1801          await removePendingVouchedDevice(deviceId);1802          return { ok: false, error: "device_revoked" };1803        }1804        await upsertPairedDeviceV3({1805          deviceId: pending.deviceId,1806          deviceName: pending.deviceName,1807          deviceType:1808            pending.deviceType as import("../shared/sync-types").PairedDevice["deviceType"],1809          longTermPublicKeyHex: pending.longTermPublicKeyHex,1810          signingPublicKeyHex: pending.signingPublicKeyHex,1811          wsPort: pending.wsPort,1812          pairedAt: pending.pairedAt,1813          trustSource: "vouched",1814        });1815        await removePendingVouchedDevice(deviceId);1816        // Newly confirmed vouched device is now a sync target — kick a round1817        // so the mesh introduction pays off without a 10-minute wait.1818        void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);1819        return { ok: true };1820      }1821      case "REJECT_VOUCHED_DEVICE": {1822        const { deviceId } = msg.payload as { deviceId: string };1823        await removePendingVouchedDevice(deviceId);1824        return { ok: true };1825      }1826      case "ROTATE_SYNC_IDENTITY": {1827        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1828        const result = await vault.rotateSyncIdentity();1829        return result.ok ? { ok: true } : { ok: false, error: result.error };1830      }18311832      default:1833        return { ok: false, error: "unknown_message_type" };1834    }1835  } catch (err) {1836    return {1837      ok: false,1838      error: err instanceof Error ? err.message : "operation_failed",1839    };1840  }1841}18421843// Returns username use-counts from the vault, sorted by frequency. Each row1844// also carries the entry's email so the popup can suggest the email/username1845// pair the user has actually used together.1846function buildIdentitySuggestions(1847  entries: VaultEntry[],1848): Array<{ email: string; username: string; count: number }> {1849  const usernameCounts = new Map<string, number>();1850  const usernameEmail = new Map<string, string>();1851  for (const entry of entries) {1852    const username = entry.username ?? "";1853    const email = (entry as VaultEntry & { email?: string }).email ?? "";1854    if (!username && !email) continue;1855    const key = username || email;1856    usernameCounts.set(key, (usernameCounts.get(key) ?? 0) + 1);1857    if (!usernameEmail.has(key)) usernameEmail.set(key, email);1858  }1859  return Array.from(usernameCounts.entries())1860    .sort((a, b) => b[1] - a[1])1861    .map(([username, count]) => ({1862      email: usernameEmail.get(username) ?? "",1863      username,1864      count,1865    }));1866}18671868/** Notify all content scripts that the vault just became available. */1869function broadcastToContentScripts(message: { type: string }): void {1870  if (!chrome.tabs?.query) return;1871  chrome.tabs.query({}, (tabs) => {1872    for (const tab of tabs) {1873      if (tab.id !== undefined) {1874        chrome.tabs.sendMessage(tab.id, message).catch(() => {1875          // Tab may not have a content script — silently ignore1876        });1877      }1878    }1879  });1880}18811882function shouldRefreshAutoLock(1883  type: MessageType,1884  sender: Partial<chrome.runtime.MessageSender>,1885): boolean {1886  if (!isExtensionPageSender(sender)) return false;1887  return (1888    type !== "HAS_VAULT" &&1889    type !== "IS_UNLOCKED" &&1890    type !== "GET_VAULT_STATUS" &&1891    type !== "LOCK" &&1892    type !== "UPDATE_SETTINGS" &&1893    type !== "GET_PENDING_PASSKEY" &&1894    type !== "GET_PASSKEY_PROVIDER_STATE" &&1895    type !== "DECLINE_CARD_SAVE" &&1896    type !== "IS_CARD_DECLINED" &&1897    type !== "AUTOFILL_QUERY" &&1898    type !== "ML_PREDICT_BATCH"1899  );1900}19011902function isAllowedCanaryWebhookUrl(value: string): boolean {1903  try {1904    const url = new URL(value);1905    if (url.protocol !== "https:") return false;19061907    const hostname = url.hostname.toLowerCase();1908    if (1909      hostname === "localhost" ||1910      hostname.endsWith(".localhost") ||1911      isPrivateIpv4(hostname) ||1912      isPrivateIpv6(hostname)1913    ) {1914      return false;1915    }19161917    return true;1918  } catch {1919    return false;1920  }1921}19221923function isPrivateIpv4(hostname: string): boolean {1924  if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {1925    return true;1926  }19271928  const octets = hostname.split(".").map((part) => Number(part));1929  if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {1930    return false;1931  }19321933  return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;1934}19351936function isPrivateIpv6(hostname: string): boolean {1937  const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();1938  // IPv4-mapped IPv6 literals embed a plain IPv4 address that the prefix1939  // list below can never match (WHATWG URL serializes [::ffff:192.168.0.1]1940  // as ::ffff:c0a8:1). Unwrap and test the embedded IPv4; unparseable mapped1941  // tails fail closed. (Codex Security 2026-08-04 LOW,1942  // ssrf.webhook-url-private-ip-filter.) DNS rebinding is out of scope for a1943  // one-shot host check.1944  const mapped = /^::ffff:(.+)$/.exec(normalized);1945  if (mapped) {1946    const tail = mapped[1] ?? "";1947    if (tail.includes(".")) return isPrivateIpv4(tail);1948    const parts = tail.split(":");1949    const [hi, lo] = parts;1950    if (1951      parts.length !== 2 ||1952      !hi ||1953      !lo ||1954      !/^[0-9a-f]{1,4}$/.test(hi) ||1955      !/^[0-9a-f]{1,4}$/.test(lo)1956    ) {1957      return true;1958    }1959    const octets = [1960      (Number.parseInt(hi, 16) >>> 8) & 0xff,1961      Number.parseInt(hi, 16) & 0xff,1962      (Number.parseInt(lo, 16) >>> 8) & 0xff,1963      Number.parseInt(lo, 16) & 0xff,1964    ];1965    return isPrivateIpv4(octets.join("."));1966  }1967  return (1968    normalized === "::1" ||1969    normalized.startsWith("fc") ||1970    normalized.startsWith("fd") ||1971    normalized.startsWith("fe80:")1972  );1973}