messages.ts

79.5 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 { predictBatch as mlPredictBatch } from "./ml";71import type { MLFieldFeatures } from "../shared/ml";72import {73  buildLocalIdentity,74  getOrCreateDeviceId,75  setDeviceName as setDeviceNameV3,76} from "./device-identity";77import {78  isDeviceRevoked,79  listPairedDevices as listPairedDevicesV3,80  renamePairedDevice as renamePairedDeviceV3,81  revokeDeviceById as revokeDeviceByIdV3,82  upsertPairedDevice as upsertPairedDeviceV3,83} from "./paired-devices";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  resolveSourceSasConfirmation,95  startRelayAdoptionReceiver,96  startRelayAdoptionSource,97} from "./relay-adoption-bg";98import {99  getSyncSettings,100  setRelayUrl,101  setSyncMode,102  type SyncMode,103} from "./sync-settings";104import {105  getSyncState,106  triggerSyncIfReachable,107} from "./sync-trigger";108import { isMessageSenderAllowed } from "./message-policy";109110const SUMMON_CAPABILITY_TTL_MS = 30_000;111type SummonCapability = { tabId: number; frameId: number; origin: string; operation: "inventory" | "fill"; entryId?: string; expiresAt: number };112const summonCapabilities = new Map<string, SummonCapability>();113114export function createSummonCapability(tabId: number, origin: string, frameId = 0): string {115  const token = crypto.randomUUID();116  summonCapabilities.set(token, { tabId, frameId, origin, operation: "inventory", expiresAt: Date.now() + SUMMON_CAPABILITY_TTL_MS });117  return token;118}119120function consumeSummonCapability(token: unknown, sender: Partial<chrome.runtime.MessageSender>, operation: "inventory" | "fill", entryId?: string): boolean {121  if (typeof token !== "string") return false;122  const cap = summonCapabilities.get(token);123  if (!cap || cap.expiresAt <= Date.now() || sender.tab?.id !== cap.tabId || (sender.frameId ?? 0) !== cap.frameId || attestedSenderOrigin(sender) !== cap.origin) return false;124  if (cap.operation !== operation) return false;125  if (operation === "inventory") cap.operation = "fill";126  else if (cap.entryId === undefined) {127    if (entryId === undefined) return false;128    summonCapabilities.delete(token);129  } else if (cap.entryId !== entryId) return false;130  else summonCapabilities.delete(token);131  return true;132}133134// Compile-time E2E flag, folded to a literal by Vite's `define` (false in135// every production build — verified in the bundled output). Mirrors the136// create-path bypass in passkey.ts so the Playwright passkey smoke can enable137// the provider without a real Pro licence; the gate is fully intact for users.138declare const __PEACH_E2E_PASSKEYS__: boolean;139const PASSKEY_E2E_PRO_BYPASS = __PEACH_E2E_PASSKEYS__ === true;140141function unhexBytes(hex: string): Uint8Array {142  if (hex.length % 2 !== 0) throw new Error("invalid hex");143  const out = new Uint8Array(hex.length / 2);144  for (let i = 0; i < out.length; i++) {145    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);146  }147  return out;148}149150/**151 * The single Pro gate. Every gated handler calls this first; the broadcast152 * lets the popup react (navigate to the License section, highlight the153 * relevant feature). Background never touches the popup view directly —154 * the popup owns navigation. See Projects.md "Pro gate rule".155 */156async function requirePro(157  feature: ProFeature,158  trigger: "user" | "background" = "user"159): Promise<{ allowed: boolean }> {160  if (PASSKEY_E2E_PRO_BYPASS && feature === "passkeys") return { allowed: true };161  if (await isProUser()) return { allowed: true };162163  if (trigger === "user") {164    // User deliberately triggered this feature — redirect the popup immediately.165    chrome.runtime166      .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })167      .catch(() => {168        // Popup may be closed — the gate signal is best-effort.169      });170  } else {171    // Background trigger (e.g. passive sync initiation) — no popup is open.172    // Queue a notice so the next popup open shows a soft banner instead of173    // silently failing.174    if (chrome.storage.session) {175      chrome.storage.session176        .set({ pendingProNotice: { feature, triggeredAt: Date.now() } })177        .catch(() => {});178    }179  }180181  return { allowed: false };182}183184// LV-3: minimum interval between non-test canary pings per webhook+entry.185const CANARY_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour186187const PRIVATE_IPV4_RANGES = [188  { prefix: "10." },189  { prefix: "127." },190  { prefix: "169.254." },191  { prefix: "192.168." },192];193194export function setupMessageHandler(): void {195  chrome.runtime.onMessage.addListener(196    (msg: unknown, sender, sendResponse) => {197      if (!isMessage(msg)) {198        sendResponse({ ok: false, error: "invalid_message" });199        return true;200      }201      handleMessage(msg, sender)202        .then(sendResponse)203        .catch((err) =>204          sendResponse({205            ok: false,206            error: err instanceof Error ? err.message : String(err),207          })208        );209      return true;210    }211  );212}213214function isMessage(m: unknown): m is Message {215  return typeof m === "object" && m !== null && "type" in m;216}217218/**219 * The browser-attested origin of the page that originated this message. A web220 * page cannot call chrome.runtime.sendMessage directly (no externally_connectable),221 * so every page-driven message arrives via the content-script relay and the222 * browser stamps `sender.origin`/`sender.url` with the real frame origin — a223 * value the page cannot forge. This is the ONLY trustworthy origin for passkey224 * rpId<->origin enforcement; payload-supplied origins are attacker-controlled.225 */226function attestedSenderOrigin(sender: Partial<chrome.runtime.MessageSender>): string | undefined {227  if (sender.origin) return sender.origin;228  if (sender.url) {229    try { return new URL(sender.url).origin; } catch { return undefined; }230  }231  return undefined;232}233234function isExtensionPageSender(sender: Partial<chrome.runtime.MessageSender>): boolean {235  return sender.url?.startsWith("chrome-extension://") === true ||236    sender.url?.startsWith("moz-extension://") === true;237}238239/** True when the message came from a page frame rather than extension UI. */240function isPageSender(sender: Partial<chrome.runtime.MessageSender>): boolean {241  return !isExtensionPageSender(sender) &&242    (sender.tab !== undefined || sender.url !== undefined);243}244245function entryDomains(url: unknown, additionalUrls: unknown): Set<string> {246  const domains = new Set<string>();247  const primary = typeof url === "string" ? extractDomain(url) : null;248  if (primary) domains.add(primary);249  if (Array.isArray(additionalUrls)) {250    for (const extra of additionalUrls) {251      if (typeof extra !== "string") continue;252      const domain = extractDomain(extra);253      if (domain) domains.add(domain);254    }255  }256  return domains;257}258259/**260 * PB-26-106: the autofill read path re-validates the frame origin against the261 * entry's domains on every request, so an unbound write path would let a262 * content script repoint an entry at its own origin and then read the263 * credential through a read path that is behaving correctly.264 *265 * Creating an entry: a page may only bind it to itself. Mutating one: a page266 * may never GROW the domain set — only shrink it, and never below its own267 * attested origin.268 *269 * Self-binding on mutation was considered and is not enough: the content270 * script on a foreign origin IS the attacker in the reported chain, so letting271 * it attach "its own" origin lets it mint the very capability the read path272 * exists to withhold. The legitimate flow that needs a new domain — the inline273 * "remember this site" toast — therefore asks the popup instead274 * (REQUEST_REMEMBER_SITE), which is an extension page and exempt here.275 *276 * Extension pages are exempt: the popup edits entries for sites it is not on.277 */278function senderMayBindEntryDomains(279  sender: Partial<chrome.runtime.MessageSender>,280  domains: Set<string>,281): boolean {282  if (isExtensionPageSender(sender)) return true;283  const domain = attestedSenderDomain(sender);284  if (domain === null) return false;285  for (const candidate of domains) {286    if (candidate !== domain) return false;287  }288  return true;289}290291function senderMayMutateEntryDomains(292  sender: Partial<chrome.runtime.MessageSender>,293  before: Set<string>,294  after: Set<string>,295): boolean {296  if (isExtensionPageSender(sender)) return true;297  const domain = attestedSenderDomain(sender);298  if (domain === null || !after.has(domain)) return false;299  for (const kept of after) {300    if (!before.has(kept)) return false;301  }302  return true;303}304305/**306 * Null for extension pages (which the callers exempt), for senders with no307 * attested origin at all, and for unparseable frame urls.308 */309function attestedSenderDomain(sender: Partial<chrome.runtime.MessageSender>): string | null {310  if (sender.tab === undefined && sender.url === undefined) return null;311  const origin = attestedSenderOrigin(sender);312  if (!origin) return null;313  try { return extractDomain(new URL(origin).hostname); } catch { return null; }314}315316function patchChangesEntryOrigin(patch: object): boolean {317  return Object.hasOwn(patch, "url") || Object.hasOwn(patch, "additionalUrls");318}319320/** The entry's domain-bearing fields as the patch would leave them. */321function patchedUrlShape(322  patch: object,323  existing: Pick<VaultEntry, "url" | "additionalUrls">,324): { url: unknown; additionalUrls: unknown } {325  const candidate = patch as { url?: unknown; additionalUrls?: unknown };326  return {327    url: Object.hasOwn(patch, "url") ? candidate.url : existing.url,328    additionalUrls: Object.hasOwn(patch, "additionalUrls")329      ? candidate.additionalUrls330      : existing.additionalUrls,331  };332}333// Resolves once restoreFromSession() has run, so handlers see the correct334// post-restore unlock state. entry.ts registers this BEFORE the message335// handler so the onMessage listener is added synchronously at SW startup336// (MV3 requirement) while unlock-dependent handlers still wait for restore.337let restoreReady: Promise<void> = Promise.resolve();338339/** Called by entry.ts at startup with the in-flight restoreFromSession promise. */340export function setRestoreReady(p: Promise<unknown>): void {341  restoreReady = p.then(() => undefined, () => undefined);342}343344export async function handleMessage(345  msg: Message,346  sender: Partial<chrome.runtime.MessageSender> = {}347): Promise<MessageResponse> {348  try {349    // Wait for session-restore to finish before reading any unlock/vault state.350    // On a cold SW start the popup's first message arrives while restore is351    // still in flight; without this gate IS_UNLOCKED would report locked (a352    // spurious lock screen) and the storage reads could race. The onMessage353    // listener itself is registered synchronously (entry.ts) so this gate, not354    // listener registration, is what absorbs the cold-start window.355    await restoreReady;356    // Validate sender — only allow messages from our extension pages/scripts.357    // `sender.id` is undefined for messages originating from extension UI358    // (popup/options), which is legitimate; reject only when it is defined359    // and does not match our extension id.360    if (361      sender.id !== undefined &&362      sender.id !== chrome.runtime.id363    ) {364      return { ok: false, error: "invalid_sender" };365    }366367    // PB-26-016: sender authorization is an exhaustive policy, not a368    // hand-maintained privileged denylist. A missing/unknown policy entry or a369    // background-only message fails closed before any handler runs.370    // Extension UI messages may legitimately omit sender.url. Resolve the371    // browser-attested extension origin only when a URL is present; this also372    // keeps test/runtime shims without getURL fail-closed for URL-bearing373    // senders instead of throwing before policy evaluation.374    const extensionOrigin = sender.url && typeof chrome.runtime.getURL === "function"375      ? chrome.runtime.getURL("")376      : "";377    if (!isMessageSenderAllowed(msg.type, sender, chrome.runtime.id, extensionOrigin)) {378      return { ok: false, error: "invalid_sender" };379    }380381    if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {382      touchAutoLock();383    }384385    switch (msg.type) {386      case "CREATE_VAULT": {387        const { password } = msg.payload as { password: string };388        // Refuse to create a vault under an empty master password — the derived389        // key would be publicly known and the at-rest AES-GCM protection void390        // (audit HIGH; the CSV-import path previously passed ""). Length/strength391        // policy lives in the onboarding UI; this is the non-negotiable floor.392        if (typeof password !== "string" || password.length === 0) {393          return { ok: false, error: "invalid_payload" };394        }395        await vault.createVault(password);396        await setupAutoLock();397        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });398        onVaultUnlocked();399        return { ok: true };400      }401      case "CREATE_VAULT_FROM_ROOT_KEY": {402        const { password, rootKeyHex } = (msg.payload ?? {}) as {403          password?: unknown;404          rootKeyHex?: unknown;405        };406        if (typeof password !== "string" || password.length === 0 || typeof rootKeyHex !== "string") {407          return { ok: false, error: "invalid_payload" };408        }409410        try {411          const { vaultId } = await vault.createVaultFromRootKey(password, rootKeyHex);412          await setupAutoLock();413          broadcastToContentScripts({ type: "VAULT_UNLOCKED" });414          onVaultUnlocked();415          return { ok: true, vaultId };416        } catch (err) {417          return { ok: false, error: err instanceof Error ? err.message : String(err) };418        }419      }420      case "UNLOCK": {421        const { password } = msg.payload as { password: string };422        const ok = await vault.unlock(password);423        if (ok) {424          // KAT: verify the WebCrypto stack is healthy before the user relies on it.425          // Runs after unlock so latency is hidden behind the vault-load time.426          const kat = await runSelfTest();427          if (!kat.ok) {428            console.error("[Peach] Crypto self-test FAILED:", kat.error);429            await vault.lock();430            return { ok: false, error: "self_test_failed" };431          }432          await vault.updateSettings({ pinPreferred: false });433          await setupAutoLock();434          broadcastToContentScripts({ type: "VAULT_UNLOCKED" });435          onVaultUnlocked();436          return { ok: true };437        }438        // Distinguish corruption from wrong password so the popup can offer439        // a history-restore path instead of falsely accusing the user of440        // mistyping (M7/COR-007).441        const failure = vault.consumeLastUnlockFailureReason();442        if (failure) {443          return {444            ok: false,445            error: "vault_corrupted",446            canRestoreFromHistory: true,447          } as MessageResponse;448        }449        return { ok: false };450      }451      case "UNLOCK_WITH_PIN": {452        const { pin } = (msg.payload ?? {}) as { pin?: unknown };453        if (typeof pin !== "string") return { ok: false, error: "invalid_payload" };454        const ok = await vault.unlockWithPin(pin);455        if (!ok) {456          const status = await vault.getPinUnlockStatus();457          if (status.configured && !status.available) {458            return {459              ok: false,460              error: status.attemptsRemaining === 0461                ? "pin_attempts_exceeded"462                : "pin_session_expired",463            };464          }465          return { ok: false };466        }467        const kat = await runSelfTest();468        if (!kat.ok) {469          await vault.lock();470          return { ok: false, error: "self_test_failed" };471        }472        await vault.updateSettings({ pinPreferred: true });473        await setupAutoLock();474        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });475        onVaultUnlocked();476        return { ok: true };477      }478      case "GET_PIN_STATUS":479        return { ok: true, data: await vault.getPinUnlockStatus() };480      case "VALIDATE_RECOVERY_WORDS": {481        const { words } = msg.payload as { words: string[] };482        const invalid = findInvalidWords(words);483        return { ok: true, data: { valid: invalid.length === 0, invalid } };484      }485      case "GET_RECOVERY_WORDS": {486        // Must be unlocked and have recovery set up (vault must be v2.12+).487        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };488        // LV-4: the recovery words re-derive the ROOT KEY — the highest-489        // value secret in the vault. A session unlock (which may be hours490        // old on a shared machine) is not enough; require a fresh password491        // proof, matching the 1Password/Bitwarden pattern for revealing492        // escrow material. EXEMPTION: the create-vault / rotation confirmation493        // window (peach_recovery_pending) shows the words immediately after494        // the user authenticated, and that confirmation UI has no password to495        // resend — skip the re-auth there only.496        if (!(await isRecoveryConfirmPending())) {497          const { password } = (msg.payload ?? {}) as { password?: string };498          if (typeof password !== "string" || !password) {499            return { ok: false, error: "password_required" };500          }501          if (!(await vault.verifyMainPassword(password))) {502            return { ok: false, error: "wrong_password" };503          }504        }505        try {506          const words = await vault.getRecoveryWords();507          return { ok: true, data: words };508        } catch {509          return { ok: false, error: "recovery_not_setup" };510        }511      }512      case "GET_RECOVERY_CONFIRM_PENDING":513        return { ok: true, data: await isRecoveryConfirmPending() };514      case "SET_RECOVERY_CONFIRM_PENDING":515        await setRecoveryConfirmPending();516        return { ok: true };517      case "CLEAR_RECOVERY_CONFIRM_PENDING":518        await clearRecoveryConfirmPending();519        return { ok: true };520      case "RECOVER_WITH_WORDS": {521        // User has lost their password but has their 12 recovery words.522        // We: (1) validate the words, (2) re-encrypt vault with new password.523        const { words, newPassword } = msg.payload as {524          words: string[];525          newPassword: string;526        };527        const stored = await vault.loadSlotA();528        if (!stored) return { ok: false, error: "no_vault" };529        if (!stored.recovery_salt || !stored.recovery_wrapped_root_key) {530          return { ok: false, error: "recovery_not_setup" };531        }532        // Unwrap the root key from the recovery escrow.533        try {534          await vault.unwrapRootKeyForRecovery(535            stored.recovery_wrapped_root_key,536            stored.recovery_nonce ?? "",537            stored.recovery_tag ?? "",538            stored.recovery_salt,539            words540          );541        } catch {542          return { ok: false, error: "invalid_recovery_words" };543        }544        // Re-key: derive fresh salt + key from new password.545        const newSalt = vault.generateSalt();546        const newKey = await vault.deriveKey(newPassword, newSalt);547        // Re-wrap root key under the new password's derived key so the same548        // recovery words continue to work after the password change.549        await vault.rewrapRootKeyForPassword(newKey, newSalt);550        // Re-encrypt vault entries with the new key.551        await vault.changePasswordWithNewKey(newSalt, newKey);552        await setupAutoLock();553        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });554        return { ok: true };555      }556      case "LOCK":557        vault.lock();558        return { ok: true };559      case "IS_UNLOCKED":560        return { ok: true, data: vault.isUnlocked() };561      case "GET_VAULT_STATUS":562        return { ok: true, unlocked: vault.isUnlocked() };563      case "HAS_VAULT":564        return { ok: true, data: await vault.hasVault() };565      case "GET_ENTRIES":566        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };567        return { ok: true, data: vault.getEntries() };568      case "ADD_ENTRY": {569        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };570        const payload = msg.payload;571        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {572          return { ok: false, error: "invalid_payload" };573        }574        // PB-26-106: a page may only create an entry bound to itself. Entries575        // carrying no url have no domain surface to bind, so they are exempt.576        if (isPageSender(sender)) {577          const draft = payload as { url?: unknown; additionalUrls?: unknown };578          const domains = entryDomains(draft.url, draft.additionalUrls);579          if (domains.size > 0 && !senderMayBindEntryDomains(sender, domains)) {580            return { ok: false, error: "origin_binding_required" };581          }582        }583        const entry = await vault.addEntry(payload as Parameters<typeof vault.addEntry>[0]);584        return { ok: true, data: entry };585      }586      case "BATCH_ADD_ENTRIES": {587        const payload = msg.payload as { entries?: unknown } | null | undefined;588        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {589          return { ok: false, error: "invalid_payload" };590        }591        if (!Array.isArray(payload.entries)) {592          return { ok: false, error: "invalid_payload" };593        }594        const added = await vault.batchAddEntries(payload.entries as Parameters<typeof vault.batchAddEntries>[0]);595        return { ok: true, data: added };596      }597      case "RESTORE_ENTRIES": {598        const payload = msg.payload as599          | { entries?: unknown; mode?: unknown; settings?: unknown }600          | null601          | undefined;602        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {603          return { ok: false, error: "invalid_payload" };604        }605        if (!Array.isArray(payload.entries)) {606          return { ok: false, error: "invalid_payload" };607        }608        if (payload.mode !== "replace" && payload.mode !== "merge") {609          return { ok: false, error: "invalid_payload" };610        }611        const summary = await vault.restoreEntries(612          payload.entries as Parameters<typeof vault.restoreEntries>[0],613          payload.mode614        );615        if (payload.settings && typeof payload.settings === "object" && !Array.isArray(payload.settings)) {616          await vault.updateSettings(payload.settings as vault.VaultSettings);617        }618        return { ok: true, data: summary };619      }620      case "CLEAR_VAULT_ENTRIES": {621        await vault.clearVaultEntries("REPLACE_ALL");622        return { ok: true };623      }624      case "GET_VAULT_HISTORY": {625        const snapshots = await vault.getVaultHistory();626        return { ok: true, data: snapshots };627      }628      case "RESTORE_VAULT_SNAPSHOT": {629        const { snapshotId } = msg.payload as { snapshotId: string };630        const result = await vault.restoreVaultSnapshot(snapshotId);631        if (!result.ok) return { ok: false, error: result.error };632        broadcastToContentScripts({ type: "VAULT_LOCKED" });633        return { ok: true };634      }635      case "COUNT_DUPLICATES": {636        return { ok: true, data: vault.countDuplicates() };637      }638      case "DEDUPLICATE_ENTRIES": {639        const removed = await vault.deduplicateEntries();640        return { ok: true, data: removed };641      }642      case "UPDATE_ENTRY": {643        const payload = msg.payload as { id?: unknown; patch?: unknown } | null | undefined;644        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {645          return { ok: false, error: "invalid_payload" };646        }647        const { id, patch } = payload;648        if (typeof id !== "string" || !id || !patch || typeof patch !== "object" || Array.isArray(patch)) {649          return { ok: false, error: "invalid_payload" };650        }651        const existingEntry = vault.getEntryById(id);652        if (!existingEntry) return { ok: false, error: "entry_not_found" };653        if (patchChangesEntryOrigin(patch) || isPageSender(sender)) {654          const before = entryDomains(existingEntry.url, existingEntry.additionalUrls);655          const after = patchedUrlShape(patch, existingEntry);656          if (!senderMayMutateEntryDomains(657            sender,658            before,659            entryDomains(after.url, after.additionalUrls),660          )) {661            return { ok: false, error: "origin_binding_required" };662          }663        }664        // Single enforcement point for the Canary Entries Pro gate. Only665        // gates the transition into a canary entry — clearing/disabling a666        // canary (`canary: undefined`) is always allowed, so existing667        // canary entries remain manageable.668        const incomingCanary = (patch as { canary?: unknown }).canary;669        if (incomingCanary && typeof incomingCanary === "object") {670          const existing = vault.getEntryById(id);671          if (!existing?.canary) {672            const gate = await requirePro("canary_entry");673            if (!gate.allowed) return { ok: false, error: "pro_required" };674          }675        }676        await vault.updateEntry(id, patch as Parameters<typeof vault.updateEntry>[1]);677        return { ok: true };678      }679      case "ADD_ADDITIONAL_URL": {680        const { id, url } = msg.payload as { id: string; url: string };681        const trimmed = typeof url === "string" ? url.trim() : "";682        if (!trimmed) return { ok: false, error: "url_required" };683        const existing = vault.getEntryById(id);684        if (!existing) return { ok: false, error: "entry_not_found" };685        const merged = [...(existing.additionalUrls ?? []), trimmed];686        if (!senderMayMutateEntryDomains(687          sender,688          entryDomains(existing.url, existing.additionalUrls),689          entryDomains(existing.url, merged),690        )) {691          return { ok: false, error: "origin_binding_required" };692        }693        if (existing.type !== "login") return { ok: false, error: "login_entries_only" };694        await vault.updateEntry(id, { additionalUrls: merged });695        return { ok: true };696      }697      case "REQUEST_REMEMBER_SITE": {698        // PB-26-106: growing an entry's domain set needs a decision the page699        // cannot make for itself, so the page asks and the popup confirms. A700        // page may only ask about its own attested origin — otherwise this701        // becomes the same unbound write under a friendlier name.702        const { id, url } = (msg.payload ?? {}) as { id?: unknown; url?: unknown };703        if (typeof id !== "string" || !id) return { ok: false, error: "invalid_payload" };704        const trimmed = typeof url === "string" ? url.trim() : "";705        if (!trimmed) return { ok: false, error: "url_required" };706        if (!senderMayBindEntryDomains(sender, entryDomains(trimmed, undefined))) {707          return { ok: false, error: "origin_binding_required" };708        }709        if (!vault.getEntryById(id)) return { ok: false, error: "entry_not_found" };710        if (chrome.storage.session) {711          await chrome.storage.session.set({ pendingRememberSite: { entryId: id, url: trimmed } });712        }713        try { await chrome.action.openPopup(); } catch { /* user opens via toolbar */ }714        return { ok: true };715      }716      case "DELETE_ENTRY": {717        const { id } = msg.payload as { id: string };718        await vault.deleteEntry(id);719        return { ok: true };720      }721      case "GET_DELETED_ENTRIES":722        return { ok: true, data: vault.getDeletedEntries() };723      case "RESTORE_ENTRY": {724        const { id } = msg.payload as { id: string };725        await vault.restoreEntry(id);726        return { ok: true };727      }728      case "PURGE_ENTRY": {729        const { id } = msg.payload as { id: string };730        await vault.purgeEntry(id);731        return { ok: true };732      }733      case "PURGE_EXPIRED_TRASH": {734        const { retentionMs } = msg.payload as { retentionMs: number };735        const purged = await vault.purgeExpiredTrash(retentionMs);736        return { ok: true, data: purged };737      }738      case "SAVE_NOTE": {739        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };740        const { id, title, body } = msg.payload as { id?: string; title: string; body: string };741        const now = Date.now();742        if (id) {743          const existing = vault.getEntryById(id);744          if (!existing) return { ok: false, error: "not_found" };745          await vault.updateEntry(id, { title: title || "Untitled", notes: body, modifiedAt: now });746          return { ok: true, data: vault.getEntryById(id) };747        } else {748          // PCH-01-026 #10: addEntry stamps its own createdAt/modifiedAt749          // (EntryDraft omits both), so passing them was the only reason the750          // `as any` cast existed. Dropping them makes the call type-check.751          const entry = await vault.addEntry({752            type: "note",753            title: title || "Untitled",754            notes: body,755          });756          return { ok: true, data: entry };757        }758      }759      case "DELETE_NOTE": {760        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };761        const { id } = msg.payload as { id: string };762        await vault.deleteEntry(id);763        return { ok: true };764      }765      case "GET_NOTES": {766        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };767        const notes = vault.getEntries().filter(768          (e) => e.type === "note" && !e.deletedAt769        );770        return { ok: true, data: notes } as MessageResponse;771      }772      case "SEARCH_VAULT": {773        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };774        // AUD-002: shape-validate the payload before calling string methods.775        // A raw cast lets a malformed message crash the handler with TypeError.776        const { query } = (msg.payload ?? {}) as { query?: unknown };777        if (typeof query !== "string") {778          return { ok: false, error: "invalid_payload" };779        }780        const q = query.toLowerCase();781        const searchResults = vault.getEntries().filter(782          (e) =>783            !e.deletedAt &&784            (e.title.toLowerCase().includes(q) ||785              e.notes?.toLowerCase().includes(q) ||786              e.username?.toLowerCase().includes(q))787        );788        return { ok: true, data: searchResults } as MessageResponse;789      }790      case "AUTOFILL_QUERY": {791        if (!vault.isUnlocked()) {792          return { ok: false, error: "vault_locked" };793        }794        const { domain, kind, capability } = msg.payload as {795          domain: string;796          kind?: "login" | "card" | "totp";797          capability?: string;798        };799        const includeAll = consumeSummonCapability(capability, sender, "inventory");800        // kind="card": return saved cards as compact summaries for the801        // inline card silver platter. Cards are not domain-scoped — every802        // saved card is a candidate on every checkout page — so domain is803        // ignored when kind === "card".804        if (kind === "card") {805          if (!includeAll) return { ok: true, data: [] };806          const cards = vault.getEntries().filter((e) => e.type === "card");807          return {808            ok: true,809            data: cards.map((e) => ({810              id: e.id,811              title: e.cardNickname || e.title,812              cardholderName: e.cardholderName,813              last4: e.last4,814              expMonth: e.expMonth,815              expYear: e.expYear,816              network: e.network,817            })),818          };819        }820        if (kind === "totp") {821          const matches = (822            includeAll823              ? vault.getEntries().filter((entry) => entry.type !== "note" && entry.type !== "card")824              : vault.queryDomain(domain)825          ).filter((entry) => !isPasskeyOnly(entry) && Boolean(entry.totp));826          return {827            ok: true,828            data: matches.map((e) => ({829              id: e.id,830              title: e.title,831              username: e.username,832              email: e.email,833              url: e.url,834              hasTotp: true,835            })),836          };837        }838        // The inline selector fills username+password into a form. Passkey-839        // only entries have no password to fill; sign-in for those goes840        // through the WebAuthn path instead. Filter them out everywhere so841        // the user doesn't see four "GitHub" rows that mostly do nothing.842        // Notes and cards never belonged here either.843        const matches = (844          includeAll845            ? vault846                .getEntries()847                .filter((entry) => entry.type !== "note" && entry.type !== "card")848            : vault.queryDomain(domain)849        ).filter((entry) => !isPasskeyOnly(entry));850        return {851          ok: true,852          data: matches.map((e) =>853            includeAll854              ? {855                  id: e.id,856                  title: e.title,857                  // username/email/url shipped so the inline popup's search858                  // affordance can match on them, not just titles. No secrets859                  // cross the boundary — same shape as the domain-matched860                  // response below minus hasTotp (which the search list861                  // doesn't surface).862                  username: e.username,863                  email: e.email,864                  url: e.url,865                  hasPasskey: e.passkey !== undefined,866                }867              : {868                  id: e.id,869                  title: e.title,870                  username: e.username,871                  email: e.email,872                  url: e.url,873                  hasTotp: Boolean(e.totp),874                  hasPasskey: e.passkey !== undefined,875                }876          ),877        };878      }879      case "GET_ENTRY_FOR_FILL": {880        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };881        const { id, capability } = msg.payload as {882          id: string;883          capability?: string;884        };885        const entry = vault.getEntryById(id);886        if (!entry) return { ok: false, error: "entry_not_found" };887        // Passkey-only entries (passkey credential, no password) cannot be888        // filled into a form — sign-in goes through the WebAuthn path. A889        // login that ALSO has a passkey is still fillable for its password.890        if (isPasskeyOnly(entry)) return { ok: false, error: "passkey_not_fillable" };891892        // MV-1: re-validate the entry against the requesting frame's origin.893        // sender.url / sender.tab.url are browser-attested — a page cannot894        // forge them. PB-26-005: the FRAME url (sender.url) is authoritative895        // for an all_frames content script; the top-frame tab.url would896        // misattribute a cross-origin iframe's request to the top page. When897        // the request does not come from a tab at all (popup surfaces use898        // GET_ENTRY_FOR_EDIT, not this), refuse.899        if (sender.tab !== undefined || sender.url !== undefined) {900          let senderDomain: string | null = null;901          try {902            const rawUrl = sender.url ?? sender.tab?.url ?? "";903            senderDomain = rawUrl ? extractDomain(new URL(rawUrl).hostname) : null;904          } catch {905            senderDomain = null;906          }907          if (!senderDomain) return { ok: false, error: "origin_unverified" };908909          const entryDomains = new Set<string>();910          const primary = extractDomain(entry.url ?? "");911          if (primary) entryDomains.add(primary);912          for (const extra of entry.additionalUrls ?? []) {913            const d = extractDomain(extra);914            if (d) entryDomains.add(d);915          }916917          // Consume a summon capability even when the selected entry already918          // matches the page origin; otherwise a same-domain fill leaves the919          // bearer reusable for a later off-domain request.920          const capabilityAuthorized = capability !== undefined && consumeSummonCapability(capability, sender, "fill", id);921          // explicitSummon=true means the user actively picked this entry922          // from the cross-domain search affordance — an intentional923          // off-domain fill. Passive fill paths must stay domain-bound.924          if (!entryDomains.has(senderDomain) && !capabilityAuthorized) {925            // Domain mismatch — refuse silently (no oracle about which ids exist).926            return { ok: false, error: "entry_not_found" };927          }928        }929        // Strip the passkey credential AND the raw base32 TOTP seed from the930        // response. Content scripts never need either: the private key is the931        // authenticator's, and handing over the TOTP seed would let a932        // compromised content script mint valid codes indefinitely (SEV-3-C).933        // Generate the *current* 6-digit code here in the background and send934        // only that, plus the seconds left so the UI can render a countdown.935        // Also drop passwordHistory: filling needs only the *current* password.936        // Handing every past password to a content script during passive937        // autofill on an arbitrary page would widen the blast radius of a938        // compromised page far beyond the one credential being filled.939        const {940          passkey: _strippedPasskey,941          totp: seed,942          totpAlgorithm,943          totpDigits,944          totpPeriod,945          passwordHistory: _ph,946          ...safeEntry947        } = entry;948        let totpCode: string | undefined;949        let totpRemaining: number | undefined;950        if (seed) {951          try {952            // PCH-01-019: honour stored algorithm/digits/period (defaults apply953            // when these are undefined, i.e. RFC-default bare-seed entries).954            const generated = generateTOTP(seed, totpAlgorithm, totpDigits, totpPeriod);955            totpCode = generated.code;956            totpRemaining = generated.remaining;957          } catch {958            totpCode = undefined;959          }960        }961        return { ok: true, data: { ...safeEntry, totpCode, totpRemaining } };962      }963      // Trusted full-entry fetch for Peach's own inline-popup surfaces that964      // genuinely need the raw TOTP seed: the edit form (which re-saves the965      // seed and would otherwise wipe it) and nothing else. Unlike966      // GET_ENTRY_FOR_FILL this fires only on an explicit user action inside967      // the inline popup, never during passive autofill on an arbitrary page.968      // The passkey credential is still stripped — the edit form never touches it.969      case "GET_ENTRY_FOR_EDIT": {970        if (isPageSender(sender)) return { ok: false, error: "invalid_sender" };971        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };972        const { id, capability } = msg.payload as {973          id: string;974          capability?: string;975        };976        const entry = vault.getEntryById(id);977        if (!entry) return { ok: false, error: "entry_not_found" };978979        // PCH-01-006: re-validate the browser-attested sender origin, mirroring980        // GET_ENTRY_FOR_FILL (MV-1). The edit channel ships the raw TOTP seed and981        // full password history — at least as sensitive as the fill response — so982        // the boundary must be enforced here, not only by UI reachability. Today983        // the sole caller is the popup (no sender.tab, so this is a structural984        // no-op), but a future tab-context caller or an id leak would otherwise985        // inherit a full-credential oracle. Unlike the fill path we SKIP (not986        // refuse) when no sender domain resolves, to keep the popup caller working.987        // PB-26-005: the frame url (sender.url) is authoritative over the988        // top-frame sender.tab.url — mirrors the fill path.989        if (sender.tab !== undefined || sender.url !== undefined) {990          let senderDomain: string | null = null;991          try {992            const rawUrl = sender.url ?? sender.tab?.url ?? "";993            senderDomain = rawUrl ? extractDomain(new URL(rawUrl).hostname) : null;994          } catch {995            senderDomain = null;996          }997          if (senderDomain) {998            const entryDomains = new Set<string>();999            const primary = extractDomain(entry.url ?? "");1000            if (primary) entryDomains.add(primary);1001            for (const extra of entry.additionalUrls ?? []) {1002              const d = extractDomain(extra);1003              if (d) entryDomains.add(d);1004            }1005            if (!entryDomains.has(senderDomain) && !consumeSummonCapability(capability, sender, "fill", id)) {1006              return { ok: false, error: "entry_not_found" }; // no id oracle1007            }1008          }1009        }10101011        const { passkey: _passkey, ...safeEntry } = entry;1012        return { ok: true, data: safeEntry };1013      }1014      case "COPY": {1015        // AUD-002: shape-validate the payload before passing to the clipboard1016        // helper. A raw cast lets a malformed message reach the clipboard API1017        // with a non-string and throw.1018        const { text, label, alreadyWritten } = (msg.payload ?? {}) as {1019          text?: unknown;1020          label?: unknown;1021          alreadyWritten?: unknown;1022        };1023        if (typeof text !== "string" || typeof label !== "string") {1024          return { ok: false, error: "invalid_payload" };1025        }1026        await copyToClipboard(text, label, Boolean(alreadyWritten));1027        return { ok: true, clearSeconds: getEffectiveClearSeconds() };1028      }1029      case "CHECK_ALL_BREACHES": {1030        const allEntries = vault.getEntries();1031        const results: Record<string, number> = {};1032        for (const entry of allEntries) {1033          if (entry.type === "note") continue;1034          if (!entry.password) continue;1035          results[entry.id] = await vault.checkBreach(entry.password);1036          // Small delay — be a good citizen to the HIBP API1037          await new Promise((r) => setTimeout(r, 200));1038        }1039        // Persist any non-zero counts back to entries1040        for (const [id, count] of Object.entries(results)) {1041          const entry = vault.getEntryById(id);1042          if (entry && (entry.breach ?? 0) !== count) {1043            await vault.updateEntry(id, { breach: count > 0 ? count : undefined });1044          }1045        }1046        return { ok: true, data: results };1047      }1048      case "GET_SETTINGS":1049        return { ok: true, data: await vault.ensureSettingsLoaded() };1050      case "UPDATE_SETTINGS": {1051        const patch = msg.payload;1052        if (!patch || typeof patch !== "object" || Array.isArray(patch)) {1053          return { ok: false, error: "invalid_payload" };1054        }1055        await vault.updateSettings(patch as vault.VaultSettings);1056        if (typeof (patch as Record<string, unknown>).autoLockMinutes === "number") {1057          await setupAutoLock();1058          if (vault.isUnlocked()) touchAutoLock();1059        }1060        return { ok: true };1061      }1062      case "SETUP_PIN": {1063        const { currentPassword, pin } = (msg.payload ?? {}) as {1064          currentPassword?: unknown;1065          pin?: unknown;1066        };1067        if (typeof currentPassword !== "string" || typeof pin !== "string") {1068          return { ok: false, error: "invalid_payload" };1069        }1070        return (await vault.setupPinUnlock(currentPassword, pin))1071          ? { ok: true }1072          : { ok: false, error: "incorrect_current_password" };1073      }1074      case "DISABLE_PIN": {1075        const { currentPassword } = (msg.payload ?? {}) as { currentPassword?: unknown };1076        if (typeof currentPassword !== "string") return { ok: false, error: "invalid_payload" };1077        return (await vault.disablePinUnlock(currentPassword))1078          ? { ok: true }1079          : { ok: false, error: "incorrect_current_password" };1080      }1081      case "GET_PENDING_PASSKEY":1082        return { ok: true, data: await getPendingPasskeyRequest() };1083      case "APPROVE_PASSKEY": {1084        const { requestId } = msg.payload as { requestId: number };1085        await resolvePendingPasskeyRequest(requestId, true);1086        return { ok: true };1087      }1088      case "DENY_PASSKEY": {1089        const { requestId } = msg.payload as { requestId: number };1090        await resolvePendingPasskeyRequest(requestId, false);1091        return { ok: true };1092      }1093      case "GET_PASSKEY_PROVIDER_STATE":1094        return { ok: true, data: await getPasskeyProviderState() };1095      case "SET_PASSKEY_PROVIDER_ENABLED": {1096        const { enabled } = msg.payload as { enabled: boolean };1097        // Disabling is always free; gating fires only when a free user is1098        // trying to turn passkey interception on.1099        if (enabled) {1100          const gate = await requirePro("passkeys");1101          if (!gate.allowed) return { ok: false, error: "pro_required" };1102        }1103        return { ok: true, data: await setPasskeyProviderEnabled(enabled) };1104      }1105      // AUD-003: this handler POSTs `entryTitle` to a user-configured HTTPS1106      // endpoint. Intentional product behavior — users configure their own1107      // dead-man's-switch. isAllowedCanaryWebhookUrl blocks localhost, private1108      // IPs, and non-HTTPS schemes, but cannot defend against every DNS-1109      // rebinding case. The UI under CanarySection (Entry.tsx) discloses to1110      // users that the entry title leaves the device when the canary fires.1111      case "FIRE_CANARY_PING": {1112        // Canary alert fires unconditionally for any entry with canary: true.1113        // The Pro gate only governs creation/flagging of canary entries; once1114        // an entry is flagged, the alert path is a security property and must1115        // never be license-gated.1116        const { webhookUrl, entryTitle, trigger } = msg.payload as {1117          webhookUrl: string;1118          entryTitle: string;1119          trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";1120        };1121        if (!isAllowedCanaryWebhookUrl(webhookUrl)) {1122          return { ok: false, error: "invalid_webhook_url" };1123        }1124        // LV-3: debounce non-test pings per (webhook, entry) so a page that1125        // strobes focus/fill events can't turn the canary into a request1126        // firehose at the user's endpoint. Test pings are exempt — they are1127        // explicit user clicks in the settings UI and are awaited for1128        // feedback. The FIRST ping in a window always goes out, so the1129        // dead-man's-switch property (canary fires on access) is preserved;1130        // only repeats within the window are coalesced.1131        if (trigger !== "test") {1132          const rlKey = `canary-last-fetch:${webhookUrl}:${entryTitle}`;1133          const stored = await chrome.storage.local.get(rlKey);1134          const last = (stored as Record<string, unknown>)[rlKey];1135          if (typeof last === "number" && Date.now() - last < CANARY_MIN_INTERVAL_MS) {1136            return { ok: true, data: { rateLimited: true } };1137          }1138          await chrome.storage.local.set({ [rlKey]: Date.now() });1139        }1140        const body = JSON.stringify({1141          event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",1142          entry: entryTitle,1143          trigger,1144          timestamp: new Date().toISOString(),1145          source: "peach-extension",1146        });1147        if (trigger === "test") {1148          // Permitted outbound fetch: user-configured canary alert endpoint1149          // (validated by isAllowedCanaryWebhookUrl above). This is the1150          // only non-HIBP outbound call in the extension — see1151          // Projects.md §3.7. Test pings are awaited so the UI can show1152          // success/failure.1153          try {1154            const res = await fetch(webhookUrl, {1155              method: "POST",1156              headers: { "Content-Type": "application/json" },1157              body,1158            });1159            return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };1160          } catch (err) {1161            return { ok: false, error: err instanceof Error ? err.message : "network_error" };1162          }1163        }1164        // Non-test triggers are fire-and-forget1165        fetch(webhookUrl, {1166          method: "POST",1167          headers: { "Content-Type": "application/json" },1168          body,1169        }).catch(() => {});1170        return { ok: true };1171      }1172      case "ACTIVATE_LICENSE": {1173        const { key } = msg.payload as { key: string };1174        const licenseInfo = await activateLicense(key);1175        return { ok: true, data: licenseInfo };1176      }1177      case "GET_LICENSE": {1178        const licenseInfo = await getLicenseInfo();1179        return { ok: true, data: licenseInfo };1180      }1181      case "GET_LICENSE_KEY": {1182        const key = await getLicenseKey();1183        return { ok: true, data: key };1184      }1185      case "DEACTIVATE_LICENSE": {1186        await deactivateLicense();1187        return { ok: true };1188      }1189      case "SHOULD_PLAY_CELEBRATION": {1190        const should = await shouldPlayCelebration();1191        return { ok: true, data: should };1192      }1193      case "MARK_CELEBRATION_PLAYED": {1194        await markCelebrationPlayed();1195        return { ok: true };1196      }1197      case "GET_CODEX_VAULT_DATA": {1198        // Pro-gated. requirePro broadcasts PRO_GATE_TRIGGERED on miss so the1199        // popup navigates to the License section without further coordination.1200        // Also privileged-page-gated (PB-26-001), and recoveryWords inside the1201        // snapshot additionally require a fresh master-password proof.1202        const gate = await requirePro("codex");1203        if (!gate.allowed) return { ok: false, error: "pro_required" };1204        const { password } = (msg.payload ?? {}) as { password?: string };1205        return await handleGetCodexVaultData(password);1206      }1207      case "ESTIMATE_CODEX_PAGES": {1208        const gate = await requirePro("codex");1209        if (!gate.allowed) return { ok: false, error: "pro_required" };1210        return await handleEstimateCodexPages();1211      }1212      case "RESTORE_FROM_CODEX": {1213        // !! IMPORTANT: intentionally NOT Pro-gated. A free device must still1214        // be able to restore a vault that was printed by a Pro user.1215        // DO NOT add a Pro gate here.1216        const payload = msg.payload as CodexRestorePayload;1217        return await handleRestoreFromCodex(payload);1218      }1219      case "OPEN_PRO_GATE": {1220        // Content script requests: open popup and navigate to License section.1221        const { feature } = (msg.payload ?? {}) as { feature?: string };1222        if (chrome.storage.session) {1223          await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });1224        }1225        try { await chrome.action.openPopup(); } catch { /* badge fallback */ }1226        return { ok: true };1227      }1228      case "OPEN_POPUP": {1229        // Content script asks to open the trusted extension popup — used by the1230        // inline locked bubble so the master password is entered in the popup,1231        // never in page-hosted DOM (audit HIGH). Best-effort: openPopup needs a1232        // user gesture + Chrome 127+/Firefox; on failure the user clicks the1233        // toolbar icon. The badge already signals Peach needs attention.1234        //1235        // Set a session flag so the popup knows it was opened for inline unlock1236        // and should close itself once the vault is unlocked (returning the user1237        // to the inline autofill selector on the page).1238        if (chrome.storage.session) {1239          await chrome.storage.session.set({ peach_inline_unlock: true });1240        }1241        try { await chrome.action.openPopup(); } catch { /* user opens via toolbar */ }1242        return { ok: true };1243      }1244      case "OPEN_DETACHED_POPUP": {1245        // A window created by the action popup and then orphaned with1246        // window.close() is cascaded shut by Chrome, so the file-picker1247        // flows (OS dialogs that dismiss the popup on focus loss) ask the SW1248        // to open the standalone window instead — the SW has no window to be1249        // associated with. The query string is allowlisted so this can never1250        // open an arbitrary URL.1251        const { route } = (msg.payload ?? {}) as { route?: string };1252        const search = route === "peachscript"1253          ? "?restore=peachscript"1254          : route === "csv"1255            ? "?restore=csv"1256            : route === "codex-pdf"1257              ? "?codex=pdf"1258              : null;1259        if (!search) return { ok: false, error: "bad_route" };1260        try {1261          const popupHtml =1262            chrome.runtime.getManifest().action?.default_popup ?? "src/popup/index.html";1263          await chrome.windows.create({1264            url: chrome.runtime.getURL(`${popupHtml}${search}`),1265            type: "popup",1266            width: 320,1267            height: 550,1268          });1269          return { ok: true, data: { detached: true } };1270        } catch {1271          return { ok: false };1272        }1273      }1274      case "GET_IDENTITIES": {1275        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1276        return { ok: true, data: vault.getIdentities() };1277      }1278      case "TOUCH_AUTO_LOCK": {1279        // touchAutoLock() is already called by shouldRefreshAutoLock above;1280        // this case just gives the popup a typed message to keep the session alive.1281        return { ok: true };1282      }1283      case "GET_ADVANCED_HEALTH_REPORT": {1284        const gate = await requirePro("advanced_health");1285        if (!gate.allowed) return { ok: false, error: "pro_required" };1286        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1287        const report = await buildAdvancedHealthReport(vault.getEntries());1288        return { ok: true, data: report };1289      }1290      case "IS_DURESS_ENABLED": {1291        const enabled = await vault.isDuressEnabled();1292        return { ok: true, data: enabled };1293      }1294      case "SETUP_DURESS": {1295        // Single enforcement point for the Duress Vault Pro gate. Free users1296        // who somehow reach this handler are rejected here; the popup also1297        // pre-checks isPro() to avoid showing the setup form to free users,1298        // but server-side is the actual security boundary.1299        const gate = await requirePro("duress_vault");1300        if (!gate.allowed) return { ok: false, error: "pro_required" };1301        // MV3 service workers restart after inactivity, losing the in-memory1302        // derived key. Detect this before calling setupDuress so the popup1303        // can show a meaningful prompt to re-lock and re-unlock.1304        if (!vault.isUnlocked()) return { ok: false, error: "session_expired" };1305        const { duressPassword } = msg.payload as { duressPassword: string };1306        await vault.setupDuress(duressPassword);1307        return { ok: true };1308      }1309      case "DISABLE_DURESS": {1310        await vault.disableDuress();1311        return { ok: true };1312      }1313      case "VERIFY_MAIN_PASSWORD": {1314        const { password } = msg.payload as { password: string };1315        const ok = await vault.verifyMainPassword(password);1316        return { ok: true, data: ok };1317      }1318      case "CHANGE_PASSWORD": {1319        const { currentPassword, newPassword } = msg.payload as {1320          currentPassword: string;1321          newPassword: string;1322        };1323        const changed = await vault.changePassword(currentPassword, newPassword);1324        return changed1325          ? { ok: true }1326          : { ok: false, error: "incorrect_current_password" };1327      }1328      case "GET_SITE_PASSWORD_POLICY": {1329        const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });1330        if (!tab?.id) return { ok: true, data: null };1331        try {1332          const response = await chrome.tabs.sendMessage(tab.id, {1333            type: "GET_SITE_PASSWORD_POLICY",1334          });1335          return (response as MessageResponse) ?? { ok: true, data: null };1336        } catch {1337          return { ok: true, data: null };1338        }1339      }1340      case "RECORD_MANUAL_SUMMON": {1341        const { domain } = msg.payload as { domain: string };1342        const settings = vault.getSettings();1343        const existing = settings.manualSummonDomains?.[domain];1344        const now = Date.now();1345        await vault.updateSettings({1346          manualSummonDomains: {1347            ...settings.manualSummonDomains,1348            [domain]: {1349              firstSummonedAt: existing?.firstSummonedAt ?? now,1350              summonCount: (existing?.summonCount ?? 0) + 1,1351              lastSummonedAt: now,1352            },1353          },1354        });1355        return { ok: true };1356      }1357      case "DECLINE_CARD_SAVE": {1358        // PB-26-108: the declined-card digest is keyed by root-key material, so1359        // a decline cannot be recorded while locked. Offers are not shown while1360        // locked either, so this is unreachable in practice.1361        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1362        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1363        await vault.declineCardSaveOffer(last4, expMonth, expYear);1364        return { ok: true };1365      }1366      case "IS_CARD_DECLINED": {1367        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1368        const declined = await vault.isCardDeclined(last4, expMonth, expYear);1369        return { ok: true, data: declined };1370      }1371      case "INITIATE_FIREFOX_PASSKEY_CREATE": {1372        const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {1373          rpId?: string;1374          rpName?: string;1375          userName?: string;1376          userDisplayName?: string;1377        };1378        if (!rpId || !userName) return { ok: false, error: "rpid_and_username_required" };1379        return await initiateFirefoxPasskeyCreate({1380          rpId,1381          rpName: rpName ?? rpId,1382          userName,1383          userDisplayName,1384        }, attestedSenderOrigin(sender));1385      }13861387      case "COMPLETE_FIREFOX_PASSKEY_CREATE": {1388        const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;1389        if (!payload) return { ok: false, error: "missing_payload" };1390        return await completeFirefoxPasskeyCreate(payload);1391      }13921393      case "PASSKEY_OFFER_DECLINE": {1394        const { domain } = (msg.payload ?? {}) as { domain?: string };1395        const origin = attestedSenderOrigin(sender);1396        if (!domain || !origin || !isValidRpId(domain, origin)) return { ok: false, error: "invalid_domain" };1397        await recordPasskeyOfferDecline(domain);1398        return { ok: true };1399      }14001401      case "GET_PASSKEY_OFFER_DECLINED": {1402        const { domain } = (msg.payload ?? {}) as { domain?: string };1403        const origin = attestedSenderOrigin(sender);1404        if (!domain || !origin || !isValidRpId(domain, origin)) return { ok: false, error: "invalid_domain" };1405        return { ok: true, data: await isPasskeyOfferDeclined(domain) };1406      }14071408      case "PASSKEY_HAS_CREDENTIALS": {1409        const payload = msg.payload as {1410          rpId?: string;1411          allowCredentials?: Array<{ id: string; type?: string }>;1412        } | undefined;1413        if (!payload?.rpId) return { ok: false, error: "rpid_required" };1414        return await handlePasskeyHasCredentials({1415          rpId: payload.rpId,1416          allowCredentials: payload.allowCredentials ?? [],1417        }, attestedSenderOrigin(sender));1418      }14191420      case "PASSKEY_CREATE": {1421        const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;1422        if (!payload?.rpId || !payload?.userName) {1423          return { ok: false, error: "rpid_and_username_required" };1424        }1425        return await handleFirefoxPasskeyCreate(payload, attestedSenderOrigin(sender));1426      }14271428      case "PASSKEY_GET": {1429        const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;1430        if (!payload?.rpId) return { ok: false, error: "rpid_required" };1431        return await handleFirefoxPasskeyGet(payload, attestedSenderOrigin(sender));1432      }14331434      case "PASSKEY_AUTH_RESPONSE": {1435        const { requestId, approved } = (msg.payload ?? {}) as {1436          requestId?: string | number;1437          approved?: boolean;1438        };1439        if (requestId === undefined || requestId === null || typeof approved !== 'boolean') {1440          return { ok: false, error: 'requestId and approved required' };1441        }1442        await resolvePendingPasskeyRequest(Number(requestId), approved);1443        return { ok: true };1444      }14451446      case "GET_PASSKEY_FOR_DOMAIN": {1447        const { domain } = (msg.payload ?? {}) as { domain?: string };1448        if (!domain) return { ok: false, error: "domain_required" };1449        // Scope to the caller's own origin: a page may only ask about passkeys1450        // for a domain it actually owns, so an arbitrary page cannot enumerate1451        // which sites the user holds passkeys for or read the username (audit MEDIUM).1452        const origin = attestedSenderOrigin(sender);1453        if (!origin || !isValidRpId(domain, origin)) return { ok: true, data: null };1454        if (!vault.isUnlocked()) return { ok: true, data: null };1455        const match = vault1456          .getEntries()1457          .find((entry) => entry.passkey?.rpId === domain);1458        if (!match || !match.passkey) return { ok: true, data: null };1459        // Slim shape — only the fields State 11 needs. Never expose private1460        // key material, even though this is locked behind isUnlocked.1461        return {1462          ok: true,1463          data: {1464            rpId: match.passkey.rpId,1465            userName: match.passkey.userName,1466            browser: match.passkey.browser,1467          },1468        };1469      }14701471      case "DESTROY_VAULT": {1472        // Irreversible by design. Lock zeroes the derived key + in-memory entries;1473        // storage.local.clear() wipes every Peach key (slots, settings, license,1474        // onboarded flag, history, sync state, declined cards, recall, etc.);1475        // session.clear() wipes pending passkey requests + pro-notice queues.1476        // Caller (Panic.tsx) handles UI navigation back to onboarding.1477        //1478        // The Pro license is the one thing that must survive: the user paid for1479        // it, and destroying a vault (panic wipe, fresh start) is not a refund.1480        // Capture it before the clear and restore it after. peach_pro_celebrated1481        // rides along so the Pro celebration doesn't replay on the next unlock.1482        const preserved = await chrome.storage.local.get([1483          "peach_license",1484          "peach_pro_celebrated",1485        ]);1486        vault.lock();1487        await storageClear();1488        await vault.clearVaultHistory();1489        const toRestore: Record<string, unknown> = {};1490        if (preserved.peach_license !== undefined) toRestore.peach_license = preserved.peach_license;1491        if (preserved.peach_pro_celebrated !== undefined) toRestore.peach_pro_celebrated = preserved.peach_pro_celebrated;1492        if (Object.keys(toRestore).length > 0) {1493          await chrome.storage.local.set(toRestore);1494        }1495        if (chrome.storage.session) {1496          await chrome.storage.session.clear();1497        }1498        await clearRecoveryConfirmPending();1499        return { ok: true };1500      }15011502      case "GET_IDENTITY_SUGGESTIONS": {1503        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1504        const entries = vault.getEntries();1505        const suggestions = buildIdentitySuggestions(entries);1506        return { ok: true, data: { suggestions } };1507      }1508      case "ML_INIT":1509        return { ok: true };15101511      case "ML_PREDICT_BATCH": {1512        // AUD-002: shape-validate before forwarding to the ML helper.1513        const { featuresArray } = (msg.payload ?? {}) as { featuresArray?: unknown };1514        if (!Array.isArray(featuresArray)) {1515          return { ok: false, error: "invalid_payload" };1516        }1517        return { ok: true, data: mlPredictBatch(featuresArray as MLFieldFeatures[]) };1518      }15191520      // ── Vault sync (v3) ───────────────────────────────────────────────1521      // Returns the public-facing local identity needed by the popup to1522      // drive Phase-1 audio pairing. The private long-term key never1523      // leaves the background.1524      case "GET_LOCAL_IDENTITY": {1525        // Not Pro-gated: a free device may attempt to pair so it can RECEIVE a1526        // Pro license from an already-Pro peer (at least one device must be Pro).1527        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1528        // Identity derives from the stable root key, not the password-derived1529        // key, so pairing survives a master-password change (SEV-2-B).1530        const masterKeyHex = vault.getRootKeyHex();1531        if (!masterKeyHex) return { ok: false, error: "vault_locked" };1532        const masterKey = unhexBytes(masterKeyHex);1533        try {1534          const identity = await buildLocalIdentity(masterKey);1535          // If this device is Pro, advertise the signed license so the peer can1536          // activate it after SAS confirmation — pairing carries Pro across.1537          if (await isProUser()) {1538            const license = await getLicenseKey();1539            if (license) (identity as { license?: string }).license = license;1540          }1541          return { ok: true, data: identity };1542        } finally {1543          masterKey.fill(0);1544        }1545      }15461547      case "LIST_PAIRED_DEVICES": {1548        const devices = await listPairedDevicesV3();1549        return { ok: true, data: { devices } };1550      }15511552      case "RENAME_PAIRED_DEVICE": {1553        const { deviceId, name } = (msg.payload ?? {}) as { deviceId?: string; name?: string };1554        if (typeof deviceId !== "string" || !deviceId || typeof name !== "string") {1555          return { ok: false, error: "invalid_payload" };1556        }1557        await renamePairedDeviceV3(deviceId, name);1558        return { ok: true };1559      }15601561      case "UNPAIR_DEVICE": {1562        const { deviceId } = (msg.payload ?? {}) as { deviceId?: string };1563        if (typeof deviceId !== "string" || !deviceId) {1564          return { ok: false, error: "invalid_payload" };1565        }1566        // Removing a device REVOKES it rather than just dropping the local1567        // row: a plain removal is undone the moment any other peer vouches it1568        // back through a manifest, which is exactly what the relay makes easy.1569        await revokeDeviceByIdV3(deviceId, await getOrCreateDeviceId());1570        // Sync against the remaining hosts — the unpaired device no longer1571        // matters but the next alarm tick is up to ten minutes away.1572        void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);1573        return { ok: true };1574      }15751576      case "RENAME_LOCAL_DEVICE": {1577        const { name } = (msg.payload ?? {}) as { name?: string };1578        if (typeof name !== "string") return { ok: false, error: "invalid_payload" };1579        await setDeviceNameV3(name);1580        return { ok: true };1581      }15821583      // ── Relay pairing ─────────────────────────────────────────────────────1584      // Not Pro-gated (a free device may pair to receive Pro/sync). The whole NK1585      // session runs in the background; the SAS and the terminal result reach1586      // the popup as ADOPTION_SOURCE_SAS / ADOPTION_SOURCE_RESULT pushes.1587      case "START_ADOPTION_SOURCE": {1588        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1589        if (isRelayAdoptionSourceInFlight()) {1590          return { ok: false, error: "adoption_source_in_progress" };1591        }1592        try {1593          return { ok: true, data: await startRelayAdoptionSource() };1594        } catch (error) {1595          return {1596            ok: false,1597            error: error instanceof Error ? error.message : "adoption_source_failed",1598          };1599        }1600      }16011602      // Onboarding receiver — empty extension shows a QR, phone scans and1603      // sends the vault. The popup supplies the local-only password here so1604      // the background can wrap the adopted root key at rest without ever1605      // asking the user to retype it.1606      case "START_ADOPTION_RECEIVER": {1607        if (isRelayAdoptionSourceInFlight()) {1608          return { ok: false, error: "adoption_receiver_in_progress" };1609        }1610        const password = (msg.payload as { password?: unknown } | undefined)?.password;1611        if (typeof password !== "string" || password.length === 0) {1612          return { ok: false, error: "invalid_payload" };1613        }1614        try {1615          return { ok: true, data: await startRelayAdoptionReceiver(password) };1616        } catch (error) {1617          return {1618            ok: false,1619            error: error instanceof Error ? error.message : "adoption_receiver_failed",1620          };1621        }1622      }16231624      // Popup→bg counterpart to ADOPTION_SOURCE_SAS (receiver flow).1625      // The receiver now auto-accepts the SAS (both sides derive it from the1626      // same Noise handshake hash, so the codes match by construction) so1627      // the popup never has a SAS screen to tap on. The id round-trips the1628      // same way so a stale late response can't approve the wrong session.1629      // Popup→bg counterpart for the source flow: Confirm/Cancel on the1630      // source's SAS screen.16311632      // Source-side mirror of RECEIVER_SAS_DECISION: the popup's confirm/cancel1633      // on the source's SAS screen. The id round-trips the same way so a stale1634      // late response can't approve the wrong session.1635      case "SOURCE_SAS_DECISION": {1636        const payload = msg.payload as { id?: unknown; ok?: unknown } | undefined;1637        if (typeof payload?.id !== "string" || typeof payload?.ok !== "boolean") {1638          return { ok: false, error: "invalid_payload" };1639        }1640        return { ok: resolveSourceSasConfirmation(payload.id, payload.ok) };1641      }16421643      case "CANCEL_ADOPTION_SOURCE": {1644        cancelRelayAdoptionSource();1645        return { ok: true };1646      }16471648      case "TRIGGER_SYNC": {1649        const payload = (msg.payload ?? {}) as {1650          reason?: "vault_change" | "foreground" | "manual";1651          force?: boolean;1652        };1653        const reason = payload.reason ?? "foreground";16541655        void triggerSyncIfReachable({ reason, force: payload.force === true });1656        return { ok: true };1657      }16581659      case "GET_SYNC_STATE": {1660        return { ok: true, data: getSyncState() };1661      }16621663      case "GET_SYNC_SETTINGS": {1664        return { ok: true, data: await getSyncSettings() };1665      }16661667      case "SET_SYNC_SETTINGS": {1668        const payload = (msg.payload ?? {}) as {1669          mode?: unknown;1670          relayUrl?: unknown;1671        };1672        try {1673          // A custom relay URL implies private mode; Peach's own relay stays1674          // public. See setRelayUrl.1675          if (payload.relayUrl !== undefined) {1676            if (typeof payload.relayUrl !== "string") {1677              return { ok: false, error: "invalid_relay_url" };1678            }1679            return { ok: true, data: await setRelayUrl(payload.relayUrl) };1680          }1681          if (payload.mode !== undefined) {1682            if (payload.mode !== "public" && payload.mode !== "private" && payload.mode !== "off") {1683              return { ok: false, error: "invalid_sync_mode" };1684            }1685            return { ok: true, data: await setSyncMode(payload.mode as SyncMode) };1686          }1687          return { ok: true, data: await getSyncSettings() };1688        } catch (error) {1689          return {1690            ok: false,1691            error: error instanceof Error ? error.message : "invalid_relay_url",1692          };1693        }1694      }16951696      case "GET_SYNC_ALERTS": {1697        const { consume } = (msg.payload ?? {}) as { consume?: boolean };1698        const alerts = await getPendingSyncAlerts();1699        // The popup reads on open and acknowledges with consume:true after1700        // rendering, so a popup crash between read and render doesn't lose1701        // the alerts.1702        if (consume) await clearPendingSyncAlerts();1703        return { ok: true, data: alerts };1704      }17051706      case "GET_PENDING_VOUCHED_DEVICES": {1707        return { ok: true, data: await listPendingVouchedDevices() };1708      }1709      case "CONFIRM_VOUCHED_DEVICE": {1710        const { deviceId } = msg.payload as { deviceId: string };1711        const pending = await getPendingVouchedDevice(deviceId);1712        if (!pending) return { ok: false, error: "not_pending" };1713        // Last gate before a vouched device becomes a sync peer. A queue entry1714        // can predate the revocation that names it, so revocation is re-checked1715        // here rather than trusted to have swept the queue.1716        if (await isDeviceRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {1717          await removePendingVouchedDevice(deviceId);1718          return { ok: false, error: "device_revoked" };1719        }1720        await upsertPairedDeviceV3({1721          deviceId: pending.deviceId,1722          deviceName: pending.deviceName,1723          deviceType:1724            pending.deviceType as import("../shared/sync-types").PairedDevice["deviceType"],1725          longTermPublicKeyHex: pending.longTermPublicKeyHex,1726          signingPublicKeyHex: pending.signingPublicKeyHex,1727          wsPort: pending.wsPort,1728          pairedAt: pending.pairedAt,1729          trustSource: "vouched",1730        });1731        await removePendingVouchedDevice(deviceId);1732        // Newly confirmed vouched device is now a sync target — kick a round1733        // so the mesh introduction pays off without a 10-minute wait.1734        void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);1735        return { ok: true };1736      }1737      case "REJECT_VOUCHED_DEVICE": {1738        const { deviceId } = msg.payload as { deviceId: string };1739        await removePendingVouchedDevice(deviceId);1740        return { ok: true };1741      }1742      case "ROTATE_SYNC_IDENTITY": {1743        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1744        const result = await vault.rotateSyncIdentity();1745        return result.ok ? { ok: true } : { ok: false, error: result.error };1746      }17471748      default:1749        return { ok: false, error: "unknown_message_type" };1750    }1751  } catch (err) {1752    return {1753      ok: false,1754      error: err instanceof Error ? err.message : "operation_failed",1755    };1756  }1757}17581759// Returns username use-counts from the vault, sorted by frequency. Each row1760// also carries the entry's email so the popup can suggest the email/username1761// pair the user has actually used together.1762function buildIdentitySuggestions(1763  entries: VaultEntry[],1764): Array<{ email: string; username: string; count: number }> {1765  const usernameCounts = new Map<string, number>();1766  const usernameEmail = new Map<string, string>();1767  for (const entry of entries) {1768    const username = entry.username ?? "";1769    const email = (entry as VaultEntry & { email?: string }).email ?? "";1770    if (!username && !email) continue;1771    const key = username || email;1772    usernameCounts.set(key, (usernameCounts.get(key) ?? 0) + 1);1773    if (!usernameEmail.has(key)) usernameEmail.set(key, email);1774  }1775  return Array.from(usernameCounts.entries())1776    .sort((a, b) => b[1] - a[1])1777    .map(([username, count]) => ({1778      email: usernameEmail.get(username) ?? "",1779      username,1780      count,1781    }));1782}17831784/** Notify all content scripts that the vault just became available. */1785function broadcastToContentScripts(message: { type: string }): void {1786  if (!chrome.tabs?.query) return;1787  chrome.tabs.query({}, (tabs) => {1788    for (const tab of tabs) {1789      if (tab.id !== undefined) {1790        chrome.tabs.sendMessage(tab.id, message).catch(() => {1791          // Tab may not have a content script — silently ignore1792        });1793      }1794    }1795  });1796}17971798function shouldRefreshAutoLock(type: MessageType): boolean {1799  return (1800    type !== "HAS_VAULT" &&1801    type !== "IS_UNLOCKED" &&1802    type !== "GET_VAULT_STATUS" &&1803    type !== "LOCK" &&1804    type !== "UPDATE_SETTINGS" &&1805    type !== "GET_PENDING_PASSKEY" &&1806    type !== "GET_PASSKEY_PROVIDER_STATE" &&1807    type !== "DECLINE_CARD_SAVE" &&1808    type !== "IS_CARD_DECLINED" &&1809    type !== "AUTOFILL_QUERY" &&1810    type !== "ML_PREDICT_BATCH"1811  );1812}18131814function isAllowedCanaryWebhookUrl(value: string): boolean {1815  try {1816    const url = new URL(value);1817    if (url.protocol !== "https:") return false;18181819    const hostname = url.hostname.toLowerCase();1820    if (1821      hostname === "localhost" ||1822      hostname.endsWith(".localhost") ||1823      isPrivateIpv4(hostname) ||1824      isPrivateIpv6(hostname)1825    ) {1826      return false;1827    }18281829    return true;1830  } catch {1831    return false;1832  }1833}18341835function isPrivateIpv4(hostname: string): boolean {1836  if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {1837    return true;1838  }18391840  const octets = hostname.split(".").map((part) => Number(part));1841  if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {1842    return false;1843  }18441845  return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;1846}18471848function isPrivateIpv6(hostname: string): boolean {1849  const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();1850  // IPv4-mapped IPv6 literals embed a plain IPv4 address that the prefix1851  // list below can never match (WHATWG URL serializes [::ffff:192.168.0.1]1852  // as ::ffff:c0a8:1). Unwrap and test the embedded IPv4; unparseable mapped1853  // tails fail closed. (Codex Security 2026-08-04 LOW,1854  // ssrf.webhook-url-private-ip-filter.) DNS rebinding is out of scope for a1855  // one-shot host check.1856  const mapped = /^::ffff:(.+)$/.exec(normalized);1857  if (mapped) {1858    const tail = mapped[1] ?? "";1859    if (tail.includes(".")) return isPrivateIpv4(tail);1860    const parts = tail.split(":");1861    const [hi, lo] = parts;1862    if (1863      parts.length !== 2 ||1864      !hi ||1865      !lo ||1866      !/^[0-9a-f]{1,4}$/.test(hi) ||1867      !/^[0-9a-f]{1,4}$/.test(lo)1868    ) {1869      return true;1870    }1871    const octets = [1872      (Number.parseInt(hi, 16) >>> 8) & 0xff,1873      Number.parseInt(hi, 16) & 0xff,1874      (Number.parseInt(lo, 16) >>> 8) & 0xff,1875      Number.parseInt(lo, 16) & 0xff,1876    ];1877    return isPrivateIpv4(octets.join("."));1878  }1879  return (1880    normalized === "::1" ||1881    normalized.startsWith("fc") ||1882    normalized.startsWith("fd") ||1883    normalized.startsWith("fe80:")1884  );1885}