messages.ts

79.1 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) cap.entryId = entryId;127  else if (cap.entryId !== entryId) return false;128  else summonCapabilities.delete(token);129  return true;130}131132// Compile-time E2E flag, folded to a literal by Vite's `define` (false in133// every production build — verified in the bundled output). Mirrors the134// create-path bypass in passkey.ts so the Playwright passkey smoke can enable135// the provider without a real Pro licence; the gate is fully intact for users.136declare const __PEACH_E2E_PASSKEYS__: boolean;137const PASSKEY_E2E_PRO_BYPASS = __PEACH_E2E_PASSKEYS__ === true;138139function unhexBytes(hex: string): Uint8Array {140  if (hex.length % 2 !== 0) throw new Error("invalid hex");141  const out = new Uint8Array(hex.length / 2);142  for (let i = 0; i < out.length; i++) {143    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);144  }145  return out;146}147148/**149 * The single Pro gate. Every gated handler calls this first; the broadcast150 * lets the popup react (navigate to the License section, highlight the151 * relevant feature). Background never touches the popup view directly —152 * the popup owns navigation. See Projects.md "Pro gate rule".153 */154async function requirePro(155  feature: ProFeature,156  trigger: "user" | "background" = "user"157): Promise<{ allowed: boolean }> {158  if (PASSKEY_E2E_PRO_BYPASS && feature === "passkeys") return { allowed: true };159  if (await isProUser()) return { allowed: true };160161  if (trigger === "user") {162    // User deliberately triggered this feature — redirect the popup immediately.163    chrome.runtime164      .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })165      .catch(() => {166        // Popup may be closed — the gate signal is best-effort.167      });168  } else {169    // Background trigger (e.g. passive sync initiation) — no popup is open.170    // Queue a notice so the next popup open shows a soft banner instead of171    // silently failing.172    if (chrome.storage.session) {173      chrome.storage.session174        .set({ pendingProNotice: { feature, triggeredAt: Date.now() } })175        .catch(() => {});176    }177  }178179  return { allowed: false };180}181182// LV-3: minimum interval between non-test canary pings per webhook+entry.183const CANARY_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour184185const PRIVATE_IPV4_RANGES = [186  { prefix: "10." },187  { prefix: "127." },188  { prefix: "169.254." },189  { prefix: "192.168." },190];191192export function setupMessageHandler(): void {193  chrome.runtime.onMessage.addListener(194    (msg: unknown, sender, sendResponse) => {195      if (!isMessage(msg)) {196        sendResponse({ ok: false, error: "invalid_message" });197        return true;198      }199      handleMessage(msg, sender)200        .then(sendResponse)201        .catch((err) =>202          sendResponse({203            ok: false,204            error: err instanceof Error ? err.message : String(err),205          })206        );207      return true;208    }209  );210}211212function isMessage(m: unknown): m is Message {213  return typeof m === "object" && m !== null && "type" in m;214}215216/**217 * The browser-attested origin of the page that originated this message. A web218 * page cannot call chrome.runtime.sendMessage directly (no externally_connectable),219 * so every page-driven message arrives via the content-script relay and the220 * browser stamps `sender.origin`/`sender.url` with the real frame origin — a221 * value the page cannot forge. This is the ONLY trustworthy origin for passkey222 * rpId<->origin enforcement; payload-supplied origins are attacker-controlled.223 */224function attestedSenderOrigin(sender: Partial<chrome.runtime.MessageSender>): string | undefined {225  if (sender.origin) return sender.origin;226  if (sender.url) {227    try { return new URL(sender.url).origin; } catch { return undefined; }228  }229  return undefined;230}231232function isExtensionPageSender(sender: Partial<chrome.runtime.MessageSender>): boolean {233  return sender.url?.startsWith("chrome-extension://") === true ||234    sender.url?.startsWith("moz-extension://") === true;235}236237/** True when the message came from a page frame rather than extension UI. */238function isPageSender(sender: Partial<chrome.runtime.MessageSender>): boolean {239  return !isExtensionPageSender(sender) &&240    (sender.tab !== undefined || sender.url !== undefined);241}242243function entryDomains(url: unknown, additionalUrls: unknown): Set<string> {244  const domains = new Set<string>();245  const primary = typeof url === "string" ? extractDomain(url) : null;246  if (primary) domains.add(primary);247  if (Array.isArray(additionalUrls)) {248    for (const extra of additionalUrls) {249      if (typeof extra !== "string") continue;250      const domain = extractDomain(extra);251      if (domain) domains.add(domain);252    }253  }254  return domains;255}256257/**258 * PB-26-106: the autofill read path re-validates the frame origin against the259 * entry's domains on every request, so an unbound write path would let a260 * content script repoint an entry at its own origin and then read the261 * credential through a read path that is behaving correctly.262 *263 * Creating an entry: a page may only bind it to itself. Mutating one: a page264 * may never GROW the domain set — only shrink it, and never below its own265 * attested origin.266 *267 * Self-binding on mutation was considered and is not enough: the content268 * script on a foreign origin IS the attacker in the reported chain, so letting269 * it attach "its own" origin lets it mint the very capability the read path270 * exists to withhold. The legitimate flow that needs a new domain — the inline271 * "remember this site" toast — therefore asks the popup instead272 * (REQUEST_REMEMBER_SITE), which is an extension page and exempt here.273 *274 * Extension pages are exempt: the popup edits entries for sites it is not on.275 */276function senderMayBindEntryDomains(277  sender: Partial<chrome.runtime.MessageSender>,278  domains: Set<string>,279): boolean {280  if (isExtensionPageSender(sender)) return true;281  const domain = attestedSenderDomain(sender);282  if (domain === null) return false;283  for (const candidate of domains) {284    if (candidate !== domain) return false;285  }286  return true;287}288289function senderMayMutateEntryDomains(290  sender: Partial<chrome.runtime.MessageSender>,291  before: Set<string>,292  after: Set<string>,293): boolean {294  if (isExtensionPageSender(sender)) return true;295  const domain = attestedSenderDomain(sender);296  if (domain === null || !after.has(domain)) return false;297  for (const kept of after) {298    if (!before.has(kept)) return false;299  }300  return true;301}302303/**304 * Null for extension pages (which the callers exempt), for senders with no305 * attested origin at all, and for unparseable frame urls.306 */307function attestedSenderDomain(sender: Partial<chrome.runtime.MessageSender>): string | null {308  if (sender.tab === undefined && sender.url === undefined) return null;309  const origin = attestedSenderOrigin(sender);310  if (!origin) return null;311  try { return extractDomain(new URL(origin).hostname); } catch { return null; }312}313314function patchChangesEntryOrigin(patch: object): boolean {315  return Object.hasOwn(patch, "url") || Object.hasOwn(patch, "additionalUrls");316}317318/** The entry's domain-bearing fields as the patch would leave them. */319function patchedUrlShape(320  patch: object,321  existing: Pick<VaultEntry, "url" | "additionalUrls">,322): { url: unknown; additionalUrls: unknown } {323  const candidate = patch as { url?: unknown; additionalUrls?: unknown };324  return {325    url: Object.hasOwn(patch, "url") ? candidate.url : existing.url,326    additionalUrls: Object.hasOwn(patch, "additionalUrls")327      ? candidate.additionalUrls328      : existing.additionalUrls,329  };330}331// Resolves once restoreFromSession() has run, so handlers see the correct332// post-restore unlock state. entry.ts registers this BEFORE the message333// handler so the onMessage listener is added synchronously at SW startup334// (MV3 requirement) while unlock-dependent handlers still wait for restore.335let restoreReady: Promise<void> = Promise.resolve();336337/** Called by entry.ts at startup with the in-flight restoreFromSession promise. */338export function setRestoreReady(p: Promise<unknown>): void {339  restoreReady = p.then(() => undefined, () => undefined);340}341342export async function handleMessage(343  msg: Message,344  sender: Partial<chrome.runtime.MessageSender> = {}345): Promise<MessageResponse> {346  try {347    // Wait for session-restore to finish before reading any unlock/vault state.348    // On a cold SW start the popup's first message arrives while restore is349    // still in flight; without this gate IS_UNLOCKED would report locked (a350    // spurious lock screen) and the storage reads could race. The onMessage351    // listener itself is registered synchronously (entry.ts) so this gate, not352    // listener registration, is what absorbs the cold-start window.353    await restoreReady;354    // Validate sender — only allow messages from our extension pages/scripts.355    // `sender.id` is undefined for messages originating from extension UI356    // (popup/options), which is legitimate; reject only when it is defined357    // and does not match our extension id.358    if (359      sender.id !== undefined &&360      sender.id !== chrome.runtime.id361    ) {362      return { ok: false, error: "invalid_sender" };363    }364365    // PB-26-016: sender authorization is an exhaustive policy, not a366    // hand-maintained privileged denylist. A missing/unknown policy entry or a367    // background-only message fails closed before any handler runs.368    // Extension UI messages may legitimately omit sender.url. Resolve the369    // browser-attested extension origin only when a URL is present; this also370    // keeps test/runtime shims without getURL fail-closed for URL-bearing371    // senders instead of throwing before policy evaluation.372    const extensionOrigin = sender.url && typeof chrome.runtime.getURL === "function"373      ? chrome.runtime.getURL("")374      : "";375    if (!isMessageSenderAllowed(msg.type, sender, chrome.runtime.id, extensionOrigin)) {376      return { ok: false, error: "invalid_sender" };377    }378379    if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {380      touchAutoLock();381    }382383    switch (msg.type) {384      case "CREATE_VAULT": {385        const { password } = msg.payload as { password: string };386        // Refuse to create a vault under an empty master password — the derived387        // key would be publicly known and the at-rest AES-GCM protection void388        // (audit HIGH; the CSV-import path previously passed ""). Length/strength389        // policy lives in the onboarding UI; this is the non-negotiable floor.390        if (typeof password !== "string" || password.length === 0) {391          return { ok: false, error: "invalid_payload" };392        }393        await vault.createVault(password);394        await setupAutoLock();395        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });396        onVaultUnlocked();397        return { ok: true };398      }399      case "CREATE_VAULT_FROM_ROOT_KEY": {400        const { password, rootKeyHex } = (msg.payload ?? {}) as {401          password?: unknown;402          rootKeyHex?: unknown;403        };404        if (typeof password !== "string" || password.length === 0 || typeof rootKeyHex !== "string") {405          return { ok: false, error: "invalid_payload" };406        }407408        try {409          const { vaultId } = await vault.createVaultFromRootKey(password, rootKeyHex);410          await setupAutoLock();411          broadcastToContentScripts({ type: "VAULT_UNLOCKED" });412          onVaultUnlocked();413          return { ok: true, vaultId };414        } catch (err) {415          return { ok: false, error: err instanceof Error ? err.message : String(err) };416        }417      }418      case "UNLOCK": {419        const { password } = msg.payload as { password: string };420        const ok = await vault.unlock(password);421        if (ok) {422          // KAT: verify the WebCrypto stack is healthy before the user relies on it.423          // Runs after unlock so latency is hidden behind the vault-load time.424          const kat = await runSelfTest();425          if (!kat.ok) {426            console.error("[Peach] Crypto self-test FAILED:", kat.error);427            await vault.lock();428            return { ok: false, error: "self_test_failed" };429          }430          await vault.updateSettings({ pinPreferred: false });431          await setupAutoLock();432          broadcastToContentScripts({ type: "VAULT_UNLOCKED" });433          onVaultUnlocked();434          return { ok: true };435        }436        // Distinguish corruption from wrong password so the popup can offer437        // a history-restore path instead of falsely accusing the user of438        // mistyping (M7/COR-007).439        const failure = vault.consumeLastUnlockFailureReason();440        if (failure) {441          return {442            ok: false,443            error: "vault_corrupted",444            canRestoreFromHistory: true,445          } as MessageResponse;446        }447        return { ok: false };448      }449      case "UNLOCK_WITH_PIN": {450        const { pin } = (msg.payload ?? {}) as { pin?: unknown };451        if (typeof pin !== "string") return { ok: false, error: "invalid_payload" };452        const ok = await vault.unlockWithPin(pin);453        if (!ok) {454          const status = await vault.getPinUnlockStatus();455          if (status.configured && !status.available) {456            return {457              ok: false,458              error: status.attemptsRemaining === 0459                ? "pin_attempts_exceeded"460                : "pin_session_expired",461            };462          }463          return { ok: false };464        }465        const kat = await runSelfTest();466        if (!kat.ok) {467          await vault.lock();468          return { ok: false, error: "self_test_failed" };469        }470        await vault.updateSettings({ pinPreferred: true });471        await setupAutoLock();472        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });473        onVaultUnlocked();474        return { ok: true };475      }476      case "GET_PIN_STATUS":477        return { ok: true, data: await vault.getPinUnlockStatus() };478      case "VALIDATE_RECOVERY_WORDS": {479        const { words } = msg.payload as { words: string[] };480        const invalid = findInvalidWords(words);481        return { ok: true, data: { valid: invalid.length === 0, invalid } };482      }483      case "GET_RECOVERY_WORDS": {484        // Must be unlocked and have recovery set up (vault must be v2.12+).485        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };486        // LV-4: the recovery words re-derive the ROOT KEY — the highest-487        // value secret in the vault. A session unlock (which may be hours488        // old on a shared machine) is not enough; require a fresh password489        // proof, matching the 1Password/Bitwarden pattern for revealing490        // escrow material. EXEMPTION: the create-vault / rotation confirmation491        // window (peach_recovery_pending) shows the words immediately after492        // the user authenticated, and that confirmation UI has no password to493        // resend — skip the re-auth there only.494        if (!(await isRecoveryConfirmPending())) {495          const { password } = (msg.payload ?? {}) as { password?: string };496          if (typeof password !== "string" || !password) {497            return { ok: false, error: "password_required" };498          }499          if (!(await vault.verifyMainPassword(password))) {500            return { ok: false, error: "wrong_password" };501          }502        }503        try {504          const words = await vault.getRecoveryWords();505          return { ok: true, data: words };506        } catch {507          return { ok: false, error: "recovery_not_setup" };508        }509      }510      case "GET_RECOVERY_CONFIRM_PENDING":511        return { ok: true, data: await isRecoveryConfirmPending() };512      case "SET_RECOVERY_CONFIRM_PENDING":513        await setRecoveryConfirmPending();514        return { ok: true };515      case "CLEAR_RECOVERY_CONFIRM_PENDING":516        await clearRecoveryConfirmPending();517        return { ok: true };518      case "RECOVER_WITH_WORDS": {519        // User has lost their password but has their 12 recovery words.520        // We: (1) validate the words, (2) re-encrypt vault with new password.521        const { words, newPassword } = msg.payload as {522          words: string[];523          newPassword: string;524        };525        const stored = await vault.loadSlotA();526        if (!stored) return { ok: false, error: "no_vault" };527        if (!stored.recovery_salt || !stored.recovery_wrapped_root_key) {528          return { ok: false, error: "recovery_not_setup" };529        }530        // Unwrap the root key from the recovery escrow.531        try {532          await vault.unwrapRootKeyForRecovery(533            stored.recovery_wrapped_root_key,534            stored.recovery_nonce ?? "",535            stored.recovery_tag ?? "",536            stored.recovery_salt,537            words538          );539        } catch {540          return { ok: false, error: "invalid_recovery_words" };541        }542        // Re-key: derive fresh salt + key from new password.543        const newSalt = vault.generateSalt();544        const newKey = await vault.deriveKey(newPassword, newSalt);545        // Re-wrap root key under the new password's derived key so the same546        // recovery words continue to work after the password change.547        await vault.rewrapRootKeyForPassword(newKey, newSalt);548        // Re-encrypt vault entries with the new key.549        await vault.changePasswordWithNewKey(newSalt, newKey);550        await setupAutoLock();551        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });552        return { ok: true };553      }554      case "LOCK":555        vault.lock();556        return { ok: true };557      case "IS_UNLOCKED":558        return { ok: true, data: vault.isUnlocked() };559      case "GET_VAULT_STATUS":560        return { ok: true, unlocked: vault.isUnlocked() };561      case "HAS_VAULT":562        return { ok: true, data: await vault.hasVault() };563      case "GET_ENTRIES":564        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };565        return { ok: true, data: vault.getEntries() };566      case "ADD_ENTRY": {567        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };568        const payload = msg.payload;569        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {570          return { ok: false, error: "invalid_payload" };571        }572        // PB-26-106: a page may only create an entry bound to itself. Entries573        // carrying no url have no domain surface to bind, so they are exempt.574        if (isPageSender(sender)) {575          const draft = payload as { url?: unknown; additionalUrls?: unknown };576          const domains = entryDomains(draft.url, draft.additionalUrls);577          if (domains.size > 0 && !senderMayBindEntryDomains(sender, domains)) {578            return { ok: false, error: "origin_binding_required" };579          }580        }581        const entry = await vault.addEntry(payload as Parameters<typeof vault.addEntry>[0]);582        return { ok: true, data: entry };583      }584      case "BATCH_ADD_ENTRIES": {585        const payload = msg.payload as { entries?: unknown } | null | undefined;586        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {587          return { ok: false, error: "invalid_payload" };588        }589        if (!Array.isArray(payload.entries)) {590          return { ok: false, error: "invalid_payload" };591        }592        const added = await vault.batchAddEntries(payload.entries as Parameters<typeof vault.batchAddEntries>[0]);593        return { ok: true, data: added };594      }595      case "RESTORE_ENTRIES": {596        const payload = msg.payload as597          | { entries?: unknown; mode?: unknown; settings?: unknown }598          | null599          | undefined;600        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {601          return { ok: false, error: "invalid_payload" };602        }603        if (!Array.isArray(payload.entries)) {604          return { ok: false, error: "invalid_payload" };605        }606        if (payload.mode !== "replace" && payload.mode !== "merge") {607          return { ok: false, error: "invalid_payload" };608        }609        const summary = await vault.restoreEntries(610          payload.entries as Parameters<typeof vault.restoreEntries>[0],611          payload.mode612        );613        if (payload.settings && typeof payload.settings === "object" && !Array.isArray(payload.settings)) {614          await vault.updateSettings(payload.settings as vault.VaultSettings);615        }616        return { ok: true, data: summary };617      }618      case "CLEAR_VAULT_ENTRIES": {619        await vault.clearVaultEntries("REPLACE_ALL");620        return { ok: true };621      }622      case "GET_VAULT_HISTORY": {623        const snapshots = await vault.getVaultHistory();624        return { ok: true, data: snapshots };625      }626      case "RESTORE_VAULT_SNAPSHOT": {627        const { snapshotId } = msg.payload as { snapshotId: string };628        const result = await vault.restoreVaultSnapshot(snapshotId);629        if (!result.ok) return { ok: false, error: result.error };630        broadcastToContentScripts({ type: "VAULT_LOCKED" });631        return { ok: true };632      }633      case "COUNT_DUPLICATES": {634        return { ok: true, data: vault.countDuplicates() };635      }636      case "DEDUPLICATE_ENTRIES": {637        const removed = await vault.deduplicateEntries();638        return { ok: true, data: removed };639      }640      case "UPDATE_ENTRY": {641        const payload = msg.payload as { id?: unknown; patch?: unknown } | null | undefined;642        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {643          return { ok: false, error: "invalid_payload" };644        }645        const { id, patch } = payload;646        if (typeof id !== "string" || !id || !patch || typeof patch !== "object" || Array.isArray(patch)) {647          return { ok: false, error: "invalid_payload" };648        }649        const existingEntry = vault.getEntryById(id);650        if (!existingEntry) return { ok: false, error: "entry_not_found" };651        if (patchChangesEntryOrigin(patch) || isPageSender(sender)) {652          const before = entryDomains(existingEntry.url, existingEntry.additionalUrls);653          const after = patchedUrlShape(patch, existingEntry);654          if (!senderMayMutateEntryDomains(655            sender,656            before,657            entryDomains(after.url, after.additionalUrls),658          )) {659            return { ok: false, error: "origin_binding_required" };660          }661        }662        // Single enforcement point for the Canary Entries Pro gate. Only663        // gates the transition into a canary entry — clearing/disabling a664        // canary (`canary: undefined`) is always allowed, so existing665        // canary entries remain manageable.666        const incomingCanary = (patch as { canary?: unknown }).canary;667        if (incomingCanary && typeof incomingCanary === "object") {668          const existing = vault.getEntryById(id);669          if (!existing?.canary) {670            const gate = await requirePro("canary_entry");671            if (!gate.allowed) return { ok: false, error: "pro_required" };672          }673        }674        await vault.updateEntry(id, patch as Parameters<typeof vault.updateEntry>[1]);675        return { ok: true };676      }677      case "ADD_ADDITIONAL_URL": {678        const { id, url } = msg.payload as { id: string; url: string };679        const trimmed = typeof url === "string" ? url.trim() : "";680        if (!trimmed) return { ok: false, error: "url_required" };681        const existing = vault.getEntryById(id);682        if (!existing) return { ok: false, error: "entry_not_found" };683        const merged = [...(existing.additionalUrls ?? []), trimmed];684        if (!senderMayMutateEntryDomains(685          sender,686          entryDomains(existing.url, existing.additionalUrls),687          entryDomains(existing.url, merged),688        )) {689          return { ok: false, error: "origin_binding_required" };690        }691        if (existing.type !== "login") return { ok: false, error: "login_entries_only" };692        await vault.updateEntry(id, { additionalUrls: merged });693        return { ok: true };694      }695      case "REQUEST_REMEMBER_SITE": {696        // PB-26-106: growing an entry's domain set needs a decision the page697        // cannot make for itself, so the page asks and the popup confirms. A698        // page may only ask about its own attested origin — otherwise this699        // becomes the same unbound write under a friendlier name.700        const { id, url } = (msg.payload ?? {}) as { id?: unknown; url?: unknown };701        if (typeof id !== "string" || !id) return { ok: false, error: "invalid_payload" };702        const trimmed = typeof url === "string" ? url.trim() : "";703        if (!trimmed) return { ok: false, error: "url_required" };704        if (!senderMayBindEntryDomains(sender, entryDomains(trimmed, undefined))) {705          return { ok: false, error: "origin_binding_required" };706        }707        if (!vault.getEntryById(id)) return { ok: false, error: "entry_not_found" };708        if (chrome.storage.session) {709          await chrome.storage.session.set({ pendingRememberSite: { entryId: id, url: trimmed } });710        }711        try { await chrome.action.openPopup(); } catch { /* user opens via toolbar */ }712        return { ok: true };713      }714      case "DELETE_ENTRY": {715        const { id } = msg.payload as { id: string };716        await vault.deleteEntry(id);717        return { ok: true };718      }719      case "GET_DELETED_ENTRIES":720        return { ok: true, data: vault.getDeletedEntries() };721      case "RESTORE_ENTRY": {722        const { id } = msg.payload as { id: string };723        await vault.restoreEntry(id);724        return { ok: true };725      }726      case "PURGE_ENTRY": {727        const { id } = msg.payload as { id: string };728        await vault.purgeEntry(id);729        return { ok: true };730      }731      case "PURGE_EXPIRED_TRASH": {732        const { retentionMs } = msg.payload as { retentionMs: number };733        const purged = await vault.purgeExpiredTrash(retentionMs);734        return { ok: true, data: purged };735      }736      case "SAVE_NOTE": {737        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };738        const { id, title, body } = msg.payload as { id?: string; title: string; body: string };739        const now = Date.now();740        if (id) {741          const existing = vault.getEntryById(id);742          if (!existing) return { ok: false, error: "not_found" };743          await vault.updateEntry(id, { title: title || "Untitled", notes: body, modifiedAt: now });744          return { ok: true, data: vault.getEntryById(id) };745        } else {746          // PCH-01-026 #10: addEntry stamps its own createdAt/modifiedAt747          // (EntryDraft omits both), so passing them was the only reason the748          // `as any` cast existed. Dropping them makes the call type-check.749          const entry = await vault.addEntry({750            type: "note",751            title: title || "Untitled",752            notes: body,753          });754          return { ok: true, data: entry };755        }756      }757      case "DELETE_NOTE": {758        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };759        const { id } = msg.payload as { id: string };760        await vault.deleteEntry(id);761        return { ok: true };762      }763      case "GET_NOTES": {764        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };765        const notes = vault.getEntries().filter(766          (e) => e.type === "note" && !e.deletedAt767        );768        return { ok: true, data: notes } as MessageResponse;769      }770      case "SEARCH_VAULT": {771        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };772        // AUD-002: shape-validate the payload before calling string methods.773        // A raw cast lets a malformed message crash the handler with TypeError.774        const { query } = (msg.payload ?? {}) as { query?: unknown };775        if (typeof query !== "string") {776          return { ok: false, error: "invalid_payload" };777        }778        const q = query.toLowerCase();779        const searchResults = vault.getEntries().filter(780          (e) =>781            !e.deletedAt &&782            (e.title.toLowerCase().includes(q) ||783              e.notes?.toLowerCase().includes(q) ||784              e.username?.toLowerCase().includes(q))785        );786        return { ok: true, data: searchResults } as MessageResponse;787      }788      case "AUTOFILL_QUERY": {789        if (!vault.isUnlocked()) {790          return { ok: false, error: "vault_locked" };791        }792        const { domain, kind, capability } = msg.payload as {793          domain: string;794          kind?: "login" | "card" | "totp";795          capability?: string;796        };797        const includeAll = consumeSummonCapability(capability, sender, "inventory");798        // kind="card": return saved cards as compact summaries for the799        // inline card silver platter. Cards are not domain-scoped — every800        // saved card is a candidate on every checkout page — so domain is801        // ignored when kind === "card".802        if (kind === "card") {803          if (!includeAll) return { ok: true, data: [] };804          const cards = vault.getEntries().filter((e) => e.type === "card");805          return {806            ok: true,807            data: cards.map((e) => ({808              id: e.id,809              title: e.cardNickname || e.title,810              cardholderName: e.cardholderName,811              last4: e.last4,812              expMonth: e.expMonth,813              expYear: e.expYear,814              network: e.network,815            })),816          };817        }818        if (kind === "totp") {819          const matches = (820            includeAll821              ? vault.getEntries().filter((entry) => entry.type !== "note" && entry.type !== "card")822              : vault.queryDomain(domain)823          ).filter((entry) => !isPasskeyOnly(entry) && Boolean(entry.totp));824          return {825            ok: true,826            data: matches.map((e) => ({827              id: e.id,828              title: e.title,829              username: e.username,830              email: e.email,831              url: e.url,832              hasTotp: true,833            })),834          };835        }836        // The inline selector fills username+password into a form. Passkey-837        // only entries have no password to fill; sign-in for those goes838        // through the WebAuthn path instead. Filter them out everywhere so839        // the user doesn't see four "GitHub" rows that mostly do nothing.840        // Notes and cards never belonged here either.841        const matches = (842          includeAll843            ? vault844                .getEntries()845                .filter((entry) => entry.type !== "note" && entry.type !== "card")846            : vault.queryDomain(domain)847        ).filter((entry) => !isPasskeyOnly(entry));848        return {849          ok: true,850          data: matches.map((e) =>851            includeAll852              ? {853                  id: e.id,854                  title: e.title,855                  // username/email/url shipped so the inline popup's search856                  // affordance can match on them, not just titles. No secrets857                  // cross the boundary — same shape as the domain-matched858                  // response below minus hasTotp (which the search list859                  // doesn't surface).860                  username: e.username,861                  email: e.email,862                  url: e.url,863                  hasPasskey: e.passkey !== undefined,864                }865              : {866                  id: e.id,867                  title: e.title,868                  username: e.username,869                  email: e.email,870                  url: e.url,871                  hasTotp: Boolean(e.totp),872                  hasPasskey: e.passkey !== undefined,873                }874          ),875        };876      }877      case "GET_ENTRY_FOR_FILL": {878        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };879        const { id, capability } = msg.payload as {880          id: string;881          capability?: string;882        };883        const entry = vault.getEntryById(id);884        if (!entry) return { ok: false, error: "entry_not_found" };885        // Passkey-only entries (passkey credential, no password) cannot be886        // filled into a form — sign-in goes through the WebAuthn path. A887        // login that ALSO has a passkey is still fillable for its password.888        if (isPasskeyOnly(entry)) return { ok: false, error: "passkey_not_fillable" };889890        // MV-1: re-validate the entry against the requesting frame's origin.891        // sender.url / sender.tab.url are browser-attested — a page cannot892        // forge them. PB-26-005: the FRAME url (sender.url) is authoritative893        // for an all_frames content script; the top-frame tab.url would894        // misattribute a cross-origin iframe's request to the top page. When895        // the request does not come from a tab at all (popup surfaces use896        // GET_ENTRY_FOR_EDIT, not this), refuse.897        if (sender.tab !== undefined || sender.url !== undefined) {898          let senderDomain: string | null = null;899          try {900            const rawUrl = sender.url ?? sender.tab?.url ?? "";901            senderDomain = rawUrl ? extractDomain(new URL(rawUrl).hostname) : null;902          } catch {903            senderDomain = null;904          }905          if (!senderDomain) return { ok: false, error: "origin_unverified" };906907          const entryDomains = new Set<string>();908          const primary = extractDomain(entry.url ?? "");909          if (primary) entryDomains.add(primary);910          for (const extra of entry.additionalUrls ?? []) {911            const d = extractDomain(extra);912            if (d) entryDomains.add(d);913          }914915          // explicitSummon=true means the user actively picked this entry916          // from the cross-domain search affordance — an intentional917          // off-domain fill. Passive fill paths must stay domain-bound.918          if (!entryDomains.has(senderDomain) && !consumeSummonCapability(capability, sender, "fill", id)) {919            // Domain mismatch — refuse silently (no oracle about which ids exist).920            return { ok: false, error: "entry_not_found" };921          }922        }923        // Strip the passkey credential AND the raw base32 TOTP seed from the924        // response. Content scripts never need either: the private key is the925        // authenticator's, and handing over the TOTP seed would let a926        // compromised content script mint valid codes indefinitely (SEV-3-C).927        // Generate the *current* 6-digit code here in the background and send928        // only that, plus the seconds left so the UI can render a countdown.929        // Also drop passwordHistory: filling needs only the *current* password.930        // Handing every past password to a content script during passive931        // autofill on an arbitrary page would widen the blast radius of a932        // compromised page far beyond the one credential being filled.933        const {934          passkey: _strippedPasskey,935          totp: seed,936          totpAlgorithm,937          totpDigits,938          totpPeriod,939          passwordHistory: _ph,940          ...safeEntry941        } = entry;942        let totpCode: string | undefined;943        let totpRemaining: number | undefined;944        if (seed) {945          try {946            // PCH-01-019: honour stored algorithm/digits/period (defaults apply947            // when these are undefined, i.e. RFC-default bare-seed entries).948            const generated = generateTOTP(seed, totpAlgorithm, totpDigits, totpPeriod);949            totpCode = generated.code;950            totpRemaining = generated.remaining;951          } catch {952            totpCode = undefined;953          }954        }955        return { ok: true, data: { ...safeEntry, totpCode, totpRemaining } };956      }957      // Trusted full-entry fetch for Peach's own inline-popup surfaces that958      // genuinely need the raw TOTP seed: the edit form (which re-saves the959      // seed and would otherwise wipe it) and nothing else. Unlike960      // GET_ENTRY_FOR_FILL this fires only on an explicit user action inside961      // the inline popup, never during passive autofill on an arbitrary page.962      // The passkey credential is still stripped — the edit form never touches it.963      case "GET_ENTRY_FOR_EDIT": {964        if (isPageSender(sender)) return { ok: false, error: "invalid_sender" };965        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };966        const { id, capability } = msg.payload as {967          id: string;968          capability?: string;969        };970        const entry = vault.getEntryById(id);971        if (!entry) return { ok: false, error: "entry_not_found" };972973        // PCH-01-006: re-validate the browser-attested sender origin, mirroring974        // GET_ENTRY_FOR_FILL (MV-1). The edit channel ships the raw TOTP seed and975        // full password history — at least as sensitive as the fill response — so976        // the boundary must be enforced here, not only by UI reachability. Today977        // the sole caller is the popup (no sender.tab, so this is a structural978        // no-op), but a future tab-context caller or an id leak would otherwise979        // inherit a full-credential oracle. Unlike the fill path we SKIP (not980        // refuse) when no sender domain resolves, to keep the popup caller working.981        // PB-26-005: the frame url (sender.url) is authoritative over the982        // top-frame sender.tab.url — mirrors the fill path.983        if (sender.tab !== undefined || sender.url !== undefined) {984          let senderDomain: string | null = null;985          try {986            const rawUrl = sender.url ?? sender.tab?.url ?? "";987            senderDomain = rawUrl ? extractDomain(new URL(rawUrl).hostname) : null;988          } catch {989            senderDomain = null;990          }991          if (senderDomain) {992            const entryDomains = new Set<string>();993            const primary = extractDomain(entry.url ?? "");994            if (primary) entryDomains.add(primary);995            for (const extra of entry.additionalUrls ?? []) {996              const d = extractDomain(extra);997              if (d) entryDomains.add(d);998            }999            if (!entryDomains.has(senderDomain) && !consumeSummonCapability(capability, sender, "fill", id)) {1000              return { ok: false, error: "entry_not_found" }; // no id oracle1001            }1002          }1003        }10041005        const { passkey: _passkey, ...safeEntry } = entry;1006        return { ok: true, data: safeEntry };1007      }1008      case "COPY": {1009        // AUD-002: shape-validate the payload before passing to the clipboard1010        // helper. A raw cast lets a malformed message reach the clipboard API1011        // with a non-string and throw.1012        const { text, label, alreadyWritten } = (msg.payload ?? {}) as {1013          text?: unknown;1014          label?: unknown;1015          alreadyWritten?: unknown;1016        };1017        if (typeof text !== "string" || typeof label !== "string") {1018          return { ok: false, error: "invalid_payload" };1019        }1020        await copyToClipboard(text, label, Boolean(alreadyWritten));1021        return { ok: true, clearSeconds: getEffectiveClearSeconds() };1022      }1023      case "CHECK_ALL_BREACHES": {1024        const allEntries = vault.getEntries();1025        const results: Record<string, number> = {};1026        for (const entry of allEntries) {1027          if (entry.type === "note") continue;1028          if (!entry.password) continue;1029          results[entry.id] = await vault.checkBreach(entry.password);1030          // Small delay — be a good citizen to the HIBP API1031          await new Promise((r) => setTimeout(r, 200));1032        }1033        // Persist any non-zero counts back to entries1034        for (const [id, count] of Object.entries(results)) {1035          const entry = vault.getEntryById(id);1036          if (entry && (entry.breach ?? 0) !== count) {1037            await vault.updateEntry(id, { breach: count > 0 ? count : undefined });1038          }1039        }1040        return { ok: true, data: results };1041      }1042      case "GET_SETTINGS":1043        return { ok: true, data: await vault.ensureSettingsLoaded() };1044      case "UPDATE_SETTINGS": {1045        const patch = msg.payload;1046        if (!patch || typeof patch !== "object" || Array.isArray(patch)) {1047          return { ok: false, error: "invalid_payload" };1048        }1049        await vault.updateSettings(patch as vault.VaultSettings);1050        if (typeof (patch as Record<string, unknown>).autoLockMinutes === "number") {1051          await setupAutoLock();1052          if (vault.isUnlocked()) touchAutoLock();1053        }1054        return { ok: true };1055      }1056      case "SETUP_PIN": {1057        const { currentPassword, pin } = (msg.payload ?? {}) as {1058          currentPassword?: unknown;1059          pin?: unknown;1060        };1061        if (typeof currentPassword !== "string" || typeof pin !== "string") {1062          return { ok: false, error: "invalid_payload" };1063        }1064        return (await vault.setupPinUnlock(currentPassword, pin))1065          ? { ok: true }1066          : { ok: false, error: "incorrect_current_password" };1067      }1068      case "DISABLE_PIN": {1069        const { currentPassword } = (msg.payload ?? {}) as { currentPassword?: unknown };1070        if (typeof currentPassword !== "string") return { ok: false, error: "invalid_payload" };1071        return (await vault.disablePinUnlock(currentPassword))1072          ? { ok: true }1073          : { ok: false, error: "incorrect_current_password" };1074      }1075      case "GET_PENDING_PASSKEY":1076        return { ok: true, data: await getPendingPasskeyRequest() };1077      case "APPROVE_PASSKEY": {1078        const { requestId } = msg.payload as { requestId: number };1079        await resolvePendingPasskeyRequest(requestId, true);1080        return { ok: true };1081      }1082      case "DENY_PASSKEY": {1083        const { requestId } = msg.payload as { requestId: number };1084        await resolvePendingPasskeyRequest(requestId, false);1085        return { ok: true };1086      }1087      case "GET_PASSKEY_PROVIDER_STATE":1088        return { ok: true, data: await getPasskeyProviderState() };1089      case "SET_PASSKEY_PROVIDER_ENABLED": {1090        const { enabled } = msg.payload as { enabled: boolean };1091        // Disabling is always free; gating fires only when a free user is1092        // trying to turn passkey interception on.1093        if (enabled) {1094          const gate = await requirePro("passkeys");1095          if (!gate.allowed) return { ok: false, error: "pro_required" };1096        }1097        return { ok: true, data: await setPasskeyProviderEnabled(enabled) };1098      }1099      // AUD-003: this handler POSTs `entryTitle` to a user-configured HTTPS1100      // endpoint. Intentional product behavior — users configure their own1101      // dead-man's-switch. isAllowedCanaryWebhookUrl blocks localhost, private1102      // IPs, and non-HTTPS schemes, but cannot defend against every DNS-1103      // rebinding case. The UI under CanarySection (Entry.tsx) discloses to1104      // users that the entry title leaves the device when the canary fires.1105      case "FIRE_CANARY_PING": {1106        // Canary alert fires unconditionally for any entry with canary: true.1107        // The Pro gate only governs creation/flagging of canary entries; once1108        // an entry is flagged, the alert path is a security property and must1109        // never be license-gated.1110        const { webhookUrl, entryTitle, trigger } = msg.payload as {1111          webhookUrl: string;1112          entryTitle: string;1113          trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";1114        };1115        if (!isAllowedCanaryWebhookUrl(webhookUrl)) {1116          return { ok: false, error: "invalid_webhook_url" };1117        }1118        // LV-3: debounce non-test pings per (webhook, entry) so a page that1119        // strobes focus/fill events can't turn the canary into a request1120        // firehose at the user's endpoint. Test pings are exempt — they are1121        // explicit user clicks in the settings UI and are awaited for1122        // feedback. The FIRST ping in a window always goes out, so the1123        // dead-man's-switch property (canary fires on access) is preserved;1124        // only repeats within the window are coalesced.1125        if (trigger !== "test") {1126          const rlKey = `canary-last-fetch:${webhookUrl}:${entryTitle}`;1127          const stored = await chrome.storage.local.get(rlKey);1128          const last = (stored as Record<string, unknown>)[rlKey];1129          if (typeof last === "number" && Date.now() - last < CANARY_MIN_INTERVAL_MS) {1130            return { ok: true, data: { rateLimited: true } };1131          }1132          await chrome.storage.local.set({ [rlKey]: Date.now() });1133        }1134        const body = JSON.stringify({1135          event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",1136          entry: entryTitle,1137          trigger,1138          timestamp: new Date().toISOString(),1139          source: "peach-extension",1140        });1141        if (trigger === "test") {1142          // Permitted outbound fetch: user-configured canary alert endpoint1143          // (validated by isAllowedCanaryWebhookUrl above). This is the1144          // only non-HIBP outbound call in the extension — see1145          // Projects.md §3.7. Test pings are awaited so the UI can show1146          // success/failure.1147          try {1148            const res = await fetch(webhookUrl, {1149              method: "POST",1150              headers: { "Content-Type": "application/json" },1151              body,1152            });1153            return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };1154          } catch (err) {1155            return { ok: false, error: err instanceof Error ? err.message : "network_error" };1156          }1157        }1158        // Non-test triggers are fire-and-forget1159        fetch(webhookUrl, {1160          method: "POST",1161          headers: { "Content-Type": "application/json" },1162          body,1163        }).catch(() => {});1164        return { ok: true };1165      }1166      case "ACTIVATE_LICENSE": {1167        const { key } = msg.payload as { key: string };1168        const licenseInfo = await activateLicense(key);1169        return { ok: true, data: licenseInfo };1170      }1171      case "GET_LICENSE": {1172        const licenseInfo = await getLicenseInfo();1173        return { ok: true, data: licenseInfo };1174      }1175      case "GET_LICENSE_KEY": {1176        const key = await getLicenseKey();1177        return { ok: true, data: key };1178      }1179      case "DEACTIVATE_LICENSE": {1180        await deactivateLicense();1181        return { ok: true };1182      }1183      case "SHOULD_PLAY_CELEBRATION": {1184        const should = await shouldPlayCelebration();1185        return { ok: true, data: should };1186      }1187      case "MARK_CELEBRATION_PLAYED": {1188        await markCelebrationPlayed();1189        return { ok: true };1190      }1191      case "GET_CODEX_VAULT_DATA": {1192        // Pro-gated. requirePro broadcasts PRO_GATE_TRIGGERED on miss so the1193        // popup navigates to the License section without further coordination.1194        // Also privileged-page-gated (PB-26-001), and recoveryWords inside the1195        // snapshot additionally require a fresh master-password proof.1196        const gate = await requirePro("codex");1197        if (!gate.allowed) return { ok: false, error: "pro_required" };1198        const { password } = (msg.payload ?? {}) as { password?: string };1199        return await handleGetCodexVaultData(password);1200      }1201      case "ESTIMATE_CODEX_PAGES": {1202        const gate = await requirePro("codex");1203        if (!gate.allowed) return { ok: false, error: "pro_required" };1204        return await handleEstimateCodexPages();1205      }1206      case "RESTORE_FROM_CODEX": {1207        // !! IMPORTANT: intentionally NOT Pro-gated. A free device must still1208        // be able to restore a vault that was printed by a Pro user.1209        // DO NOT add a Pro gate here.1210        const payload = msg.payload as CodexRestorePayload;1211        return await handleRestoreFromCodex(payload);1212      }1213      case "OPEN_PRO_GATE": {1214        // Content script requests: open popup and navigate to License section.1215        const { feature } = (msg.payload ?? {}) as { feature?: string };1216        if (chrome.storage.session) {1217          await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });1218        }1219        try { await chrome.action.openPopup(); } catch { /* badge fallback */ }1220        return { ok: true };1221      }1222      case "OPEN_POPUP": {1223        // Content script asks to open the trusted extension popup — used by the1224        // inline locked bubble so the master password is entered in the popup,1225        // never in page-hosted DOM (audit HIGH). Best-effort: openPopup needs a1226        // user gesture + Chrome 127+/Firefox; on failure the user clicks the1227        // toolbar icon. The badge already signals Peach needs attention.1228        //1229        // Set a session flag so the popup knows it was opened for inline unlock1230        // and should close itself once the vault is unlocked (returning the user1231        // to the inline autofill selector on the page).1232        if (chrome.storage.session) {1233          await chrome.storage.session.set({ peach_inline_unlock: true });1234        }1235        try { await chrome.action.openPopup(); } catch { /* user opens via toolbar */ }1236        return { ok: true };1237      }1238      case "OPEN_DETACHED_POPUP": {1239        // A window created by the action popup and then orphaned with1240        // window.close() is cascaded shut by Chrome, so the file-picker1241        // flows (OS dialogs that dismiss the popup on focus loss) ask the SW1242        // to open the standalone window instead — the SW has no window to be1243        // associated with. The query string is allowlisted so this can never1244        // open an arbitrary URL.1245        const { route } = (msg.payload ?? {}) as { route?: string };1246        const search = route === "peachscript"1247          ? "?restore=peachscript"1248          : route === "csv"1249            ? "?restore=csv"1250            : route === "codex-pdf"1251              ? "?codex=pdf"1252              : null;1253        if (!search) return { ok: false, error: "bad_route" };1254        try {1255          const popupHtml =1256            chrome.runtime.getManifest().action?.default_popup ?? "src/popup/index.html";1257          await chrome.windows.create({1258            url: chrome.runtime.getURL(`${popupHtml}${search}`),1259            type: "popup",1260            width: 320,1261            height: 550,1262          });1263          return { ok: true, data: { detached: true } };1264        } catch {1265          return { ok: false };1266        }1267      }1268      case "GET_IDENTITIES": {1269        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1270        return { ok: true, data: vault.getIdentities() };1271      }1272      case "TOUCH_AUTO_LOCK": {1273        // touchAutoLock() is already called by shouldRefreshAutoLock above;1274        // this case just gives the popup a typed message to keep the session alive.1275        return { ok: true };1276      }1277      case "GET_ADVANCED_HEALTH_REPORT": {1278        const gate = await requirePro("advanced_health");1279        if (!gate.allowed) return { ok: false, error: "pro_required" };1280        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1281        const report = await buildAdvancedHealthReport(vault.getEntries());1282        return { ok: true, data: report };1283      }1284      case "IS_DURESS_ENABLED": {1285        const enabled = await vault.isDuressEnabled();1286        return { ok: true, data: enabled };1287      }1288      case "SETUP_DURESS": {1289        // Single enforcement point for the Duress Vault Pro gate. Free users1290        // who somehow reach this handler are rejected here; the popup also1291        // pre-checks isPro() to avoid showing the setup form to free users,1292        // but server-side is the actual security boundary.1293        const gate = await requirePro("duress_vault");1294        if (!gate.allowed) return { ok: false, error: "pro_required" };1295        // MV3 service workers restart after inactivity, losing the in-memory1296        // derived key. Detect this before calling setupDuress so the popup1297        // can show a meaningful prompt to re-lock and re-unlock.1298        if (!vault.isUnlocked()) return { ok: false, error: "session_expired" };1299        const { duressPassword } = msg.payload as { duressPassword: string };1300        await vault.setupDuress(duressPassword);1301        return { ok: true };1302      }1303      case "DISABLE_DURESS": {1304        await vault.disableDuress();1305        return { ok: true };1306      }1307      case "VERIFY_MAIN_PASSWORD": {1308        const { password } = msg.payload as { password: string };1309        const ok = await vault.verifyMainPassword(password);1310        return { ok: true, data: ok };1311      }1312      case "CHANGE_PASSWORD": {1313        const { currentPassword, newPassword } = msg.payload as {1314          currentPassword: string;1315          newPassword: string;1316        };1317        const changed = await vault.changePassword(currentPassword, newPassword);1318        return changed1319          ? { ok: true }1320          : { ok: false, error: "incorrect_current_password" };1321      }1322      case "GET_SITE_PASSWORD_POLICY": {1323        const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });1324        if (!tab?.id) return { ok: true, data: null };1325        try {1326          const response = await chrome.tabs.sendMessage(tab.id, {1327            type: "GET_SITE_PASSWORD_POLICY",1328          });1329          return (response as MessageResponse) ?? { ok: true, data: null };1330        } catch {1331          return { ok: true, data: null };1332        }1333      }1334      case "RECORD_MANUAL_SUMMON": {1335        const { domain } = msg.payload as { domain: string };1336        const settings = vault.getSettings();1337        const existing = settings.manualSummonDomains?.[domain];1338        const now = Date.now();1339        await vault.updateSettings({1340          manualSummonDomains: {1341            ...settings.manualSummonDomains,1342            [domain]: {1343              firstSummonedAt: existing?.firstSummonedAt ?? now,1344              summonCount: (existing?.summonCount ?? 0) + 1,1345              lastSummonedAt: now,1346            },1347          },1348        });1349        return { ok: true };1350      }1351      case "DECLINE_CARD_SAVE": {1352        // PB-26-108: the declined-card digest is keyed by root-key material, so1353        // a decline cannot be recorded while locked. Offers are not shown while1354        // locked either, so this is unreachable in practice.1355        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1356        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1357        await vault.declineCardSaveOffer(last4, expMonth, expYear);1358        return { ok: true };1359      }1360      case "IS_CARD_DECLINED": {1361        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1362        const declined = await vault.isCardDeclined(last4, expMonth, expYear);1363        return { ok: true, data: declined };1364      }1365      case "INITIATE_FIREFOX_PASSKEY_CREATE": {1366        const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {1367          rpId?: string;1368          rpName?: string;1369          userName?: string;1370          userDisplayName?: string;1371        };1372        if (!rpId || !userName) return { ok: false, error: "rpid_and_username_required" };1373        return await initiateFirefoxPasskeyCreate({1374          rpId,1375          rpName: rpName ?? rpId,1376          userName,1377          userDisplayName,1378        }, attestedSenderOrigin(sender));1379      }13801381      case "COMPLETE_FIREFOX_PASSKEY_CREATE": {1382        const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;1383        if (!payload) return { ok: false, error: "missing_payload" };1384        return await completeFirefoxPasskeyCreate(payload);1385      }13861387      case "PASSKEY_OFFER_DECLINE": {1388        const { domain } = (msg.payload ?? {}) as { domain?: string };1389        const origin = attestedSenderOrigin(sender);1390        if (!domain || !origin || !isValidRpId(domain, origin)) return { ok: false, error: "invalid_domain" };1391        await recordPasskeyOfferDecline(domain);1392        return { ok: true };1393      }13941395      case "GET_PASSKEY_OFFER_DECLINED": {1396        const { domain } = (msg.payload ?? {}) as { domain?: string };1397        const origin = attestedSenderOrigin(sender);1398        if (!domain || !origin || !isValidRpId(domain, origin)) return { ok: false, error: "invalid_domain" };1399        return { ok: true, data: await isPasskeyOfferDeclined(domain) };1400      }14011402      case "PASSKEY_HAS_CREDENTIALS": {1403        const payload = msg.payload as {1404          rpId?: string;1405          allowCredentials?: Array<{ id: string; type?: string }>;1406        } | undefined;1407        if (!payload?.rpId) return { ok: false, error: "rpid_required" };1408        return await handlePasskeyHasCredentials({1409          rpId: payload.rpId,1410          allowCredentials: payload.allowCredentials ?? [],1411        }, attestedSenderOrigin(sender));1412      }14131414      case "PASSKEY_CREATE": {1415        const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;1416        if (!payload?.rpId || !payload?.userName) {1417          return { ok: false, error: "rpid_and_username_required" };1418        }1419        return await handleFirefoxPasskeyCreate(payload, attestedSenderOrigin(sender));1420      }14211422      case "PASSKEY_GET": {1423        const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;1424        if (!payload?.rpId) return { ok: false, error: "rpid_required" };1425        return await handleFirefoxPasskeyGet(payload, attestedSenderOrigin(sender));1426      }14271428      case "PASSKEY_AUTH_RESPONSE": {1429        const { requestId, approved } = (msg.payload ?? {}) as {1430          requestId?: string | number;1431          approved?: boolean;1432        };1433        if (requestId === undefined || requestId === null || typeof approved !== 'boolean') {1434          return { ok: false, error: 'requestId and approved required' };1435        }1436        await resolvePendingPasskeyRequest(Number(requestId), approved);1437        return { ok: true };1438      }14391440      case "GET_PASSKEY_FOR_DOMAIN": {1441        const { domain } = (msg.payload ?? {}) as { domain?: string };1442        if (!domain) return { ok: false, error: "domain_required" };1443        // Scope to the caller's own origin: a page may only ask about passkeys1444        // for a domain it actually owns, so an arbitrary page cannot enumerate1445        // which sites the user holds passkeys for or read the username (audit MEDIUM).1446        const origin = attestedSenderOrigin(sender);1447        if (!origin || !isValidRpId(domain, origin)) return { ok: true, data: null };1448        if (!vault.isUnlocked()) return { ok: true, data: null };1449        const match = vault1450          .getEntries()1451          .find((entry) => entry.passkey?.rpId === domain);1452        if (!match || !match.passkey) return { ok: true, data: null };1453        // Slim shape — only the fields State 11 needs. Never expose private1454        // key material, even though this is locked behind isUnlocked.1455        return {1456          ok: true,1457          data: {1458            rpId: match.passkey.rpId,1459            userName: match.passkey.userName,1460            browser: match.passkey.browser,1461          },1462        };1463      }14641465      case "DESTROY_VAULT": {1466        // Irreversible by design. Lock zeroes the derived key + in-memory entries;1467        // storage.local.clear() wipes every Peach key (slots, settings, license,1468        // onboarded flag, history, sync state, declined cards, recall, etc.);1469        // session.clear() wipes pending passkey requests + pro-notice queues.1470        // Caller (Panic.tsx) handles UI navigation back to onboarding.1471        //1472        // The Pro license is the one thing that must survive: the user paid for1473        // it, and destroying a vault (panic wipe, fresh start) is not a refund.1474        // Capture it before the clear and restore it after. peach_pro_celebrated1475        // rides along so the Pro celebration doesn't replay on the next unlock.1476        const preserved = await chrome.storage.local.get([1477          "peach_license",1478          "peach_pro_celebrated",1479        ]);1480        vault.lock();1481        await storageClear();1482        await vault.clearVaultHistory();1483        const toRestore: Record<string, unknown> = {};1484        if (preserved.peach_license !== undefined) toRestore.peach_license = preserved.peach_license;1485        if (preserved.peach_pro_celebrated !== undefined) toRestore.peach_pro_celebrated = preserved.peach_pro_celebrated;1486        if (Object.keys(toRestore).length > 0) {1487          await chrome.storage.local.set(toRestore);1488        }1489        if (chrome.storage.session) {1490          await chrome.storage.session.clear();1491        }1492        await clearRecoveryConfirmPending();1493        return { ok: true };1494      }14951496      case "GET_IDENTITY_SUGGESTIONS": {1497        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1498        const entries = vault.getEntries();1499        const suggestions = buildIdentitySuggestions(entries);1500        return { ok: true, data: { suggestions } };1501      }1502      case "ML_INIT":1503        return { ok: true };15041505      case "ML_PREDICT_BATCH": {1506        // AUD-002: shape-validate before forwarding to the ML helper.1507        const { featuresArray } = (msg.payload ?? {}) as { featuresArray?: unknown };1508        if (!Array.isArray(featuresArray)) {1509          return { ok: false, error: "invalid_payload" };1510        }1511        return { ok: true, data: mlPredictBatch(featuresArray as MLFieldFeatures[]) };1512      }15131514      // ── Vault sync (v3) ───────────────────────────────────────────────1515      // Returns the public-facing local identity needed by the popup to1516      // drive Phase-1 audio pairing. The private long-term key never1517      // leaves the background.1518      case "GET_LOCAL_IDENTITY": {1519        // Not Pro-gated: a free device may attempt to pair so it can RECEIVE a1520        // Pro license from an already-Pro peer (at least one device must be Pro).1521        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1522        // Identity derives from the stable root key, not the password-derived1523        // key, so pairing survives a master-password change (SEV-2-B).1524        const masterKeyHex = vault.getRootKeyHex();1525        if (!masterKeyHex) return { ok: false, error: "vault_locked" };1526        const masterKey = unhexBytes(masterKeyHex);1527        try {1528          const identity = await buildLocalIdentity(masterKey);1529          // If this device is Pro, advertise the signed license so the peer can1530          // activate it after SAS confirmation — pairing carries Pro across.1531          if (await isProUser()) {1532            const license = await getLicenseKey();1533            if (license) (identity as { license?: string }).license = license;1534          }1535          return { ok: true, data: identity };1536        } finally {1537          masterKey.fill(0);1538        }1539      }15401541      case "LIST_PAIRED_DEVICES": {1542        const devices = await listPairedDevicesV3();1543        return { ok: true, data: { devices } };1544      }15451546      case "RENAME_PAIRED_DEVICE": {1547        const { deviceId, name } = (msg.payload ?? {}) as { deviceId?: string; name?: string };1548        if (typeof deviceId !== "string" || !deviceId || typeof name !== "string") {1549          return { ok: false, error: "invalid_payload" };1550        }1551        await renamePairedDeviceV3(deviceId, name);1552        return { ok: true };1553      }15541555      case "UNPAIR_DEVICE": {1556        const { deviceId } = (msg.payload ?? {}) as { deviceId?: string };1557        if (typeof deviceId !== "string" || !deviceId) {1558          return { ok: false, error: "invalid_payload" };1559        }1560        // Removing a device REVOKES it rather than just dropping the local1561        // row: a plain removal is undone the moment any other peer vouches it1562        // back through a manifest, which is exactly what the relay makes easy.1563        await revokeDeviceByIdV3(deviceId, await getOrCreateDeviceId());1564        // Sync against the remaining hosts — the unpaired device no longer1565        // matters but the next alarm tick is up to ten minutes away.1566        void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);1567        return { ok: true };1568      }15691570      case "RENAME_LOCAL_DEVICE": {1571        const { name } = (msg.payload ?? {}) as { name?: string };1572        if (typeof name !== "string") return { ok: false, error: "invalid_payload" };1573        await setDeviceNameV3(name);1574        return { ok: true };1575      }15761577      // ── Relay pairing ─────────────────────────────────────────────────────1578      // Not Pro-gated (a free device may pair to receive Pro/sync). The whole NK1579      // session runs in the background; the SAS and the terminal result reach1580      // the popup as ADOPTION_SOURCE_SAS / ADOPTION_SOURCE_RESULT pushes.1581      case "START_ADOPTION_SOURCE": {1582        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1583        if (isRelayAdoptionSourceInFlight()) {1584          return { ok: false, error: "adoption_source_in_progress" };1585        }1586        try {1587          return { ok: true, data: await startRelayAdoptionSource() };1588        } catch (error) {1589          return {1590            ok: false,1591            error: error instanceof Error ? error.message : "adoption_source_failed",1592          };1593        }1594      }15951596      // Onboarding receiver — empty extension shows a QR, phone scans and1597      // sends the vault. The popup supplies the local-only password here so1598      // the background can wrap the adopted root key at rest without ever1599      // asking the user to retype it.1600      case "START_ADOPTION_RECEIVER": {1601        if (isRelayAdoptionSourceInFlight()) {1602          return { ok: false, error: "adoption_receiver_in_progress" };1603        }1604        const password = (msg.payload as { password?: unknown } | undefined)?.password;1605        if (typeof password !== "string" || password.length === 0) {1606          return { ok: false, error: "invalid_payload" };1607        }1608        try {1609          return { ok: true, data: await startRelayAdoptionReceiver(password) };1610        } catch (error) {1611          return {1612            ok: false,1613            error: error instanceof Error ? error.message : "adoption_receiver_failed",1614          };1615        }1616      }16171618      // Popup→bg counterpart to ADOPTION_SOURCE_SAS (receiver flow).1619      // The receiver now auto-accepts the SAS (both sides derive it from the1620      // same Noise handshake hash, so the codes match by construction) so1621      // the popup never has a SAS screen to tap on. The id round-trips the1622      // same way so a stale late response can't approve the wrong session.1623      // Popup→bg counterpart for the source flow: Confirm/Cancel on the1624      // source's SAS screen.16251626      // Source-side mirror of RECEIVER_SAS_DECISION: the popup's confirm/cancel1627      // on the source's SAS screen. The id round-trips the same way so a stale1628      // late response can't approve the wrong session.1629      case "SOURCE_SAS_DECISION": {1630        const payload = msg.payload as { id?: unknown; ok?: unknown } | undefined;1631        if (typeof payload?.id !== "string" || typeof payload?.ok !== "boolean") {1632          return { ok: false, error: "invalid_payload" };1633        }1634        return { ok: resolveSourceSasConfirmation(payload.id, payload.ok) };1635      }16361637      case "CANCEL_ADOPTION_SOURCE": {1638        cancelRelayAdoptionSource();1639        return { ok: true };1640      }16411642      case "TRIGGER_SYNC": {1643        const payload = (msg.payload ?? {}) as {1644          reason?: "vault_change" | "foreground" | "manual";1645          force?: boolean;1646        };1647        const reason = payload.reason ?? "foreground";16481649        void triggerSyncIfReachable({ reason, force: payload.force === true });1650        return { ok: true };1651      }16521653      case "GET_SYNC_STATE": {1654        return { ok: true, data: getSyncState() };1655      }16561657      case "GET_SYNC_SETTINGS": {1658        return { ok: true, data: await getSyncSettings() };1659      }16601661      case "SET_SYNC_SETTINGS": {1662        const payload = (msg.payload ?? {}) as {1663          mode?: unknown;1664          relayUrl?: unknown;1665        };1666        try {1667          // A custom relay URL implies private mode; Peach's own relay stays1668          // public. See setRelayUrl.1669          if (payload.relayUrl !== undefined) {1670            if (typeof payload.relayUrl !== "string") {1671              return { ok: false, error: "invalid_relay_url" };1672            }1673            return { ok: true, data: await setRelayUrl(payload.relayUrl) };1674          }1675          if (payload.mode !== undefined) {1676            if (payload.mode !== "public" && payload.mode !== "private" && payload.mode !== "off") {1677              return { ok: false, error: "invalid_sync_mode" };1678            }1679            return { ok: true, data: await setSyncMode(payload.mode as SyncMode) };1680          }1681          return { ok: true, data: await getSyncSettings() };1682        } catch (error) {1683          return {1684            ok: false,1685            error: error instanceof Error ? error.message : "invalid_relay_url",1686          };1687        }1688      }16891690      case "GET_SYNC_ALERTS": {1691        const { consume } = (msg.payload ?? {}) as { consume?: boolean };1692        const alerts = await getPendingSyncAlerts();1693        // The popup reads on open and acknowledges with consume:true after1694        // rendering, so a popup crash between read and render doesn't lose1695        // the alerts.1696        if (consume) await clearPendingSyncAlerts();1697        return { ok: true, data: alerts };1698      }16991700      case "GET_PENDING_VOUCHED_DEVICES": {1701        return { ok: true, data: await listPendingVouchedDevices() };1702      }1703      case "CONFIRM_VOUCHED_DEVICE": {1704        const { deviceId } = msg.payload as { deviceId: string };1705        const pending = await getPendingVouchedDevice(deviceId);1706        if (!pending) return { ok: false, error: "not_pending" };1707        // Last gate before a vouched device becomes a sync peer. A queue entry1708        // can predate the revocation that names it, so revocation is re-checked1709        // here rather than trusted to have swept the queue.1710        if (await isDeviceRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {1711          await removePendingVouchedDevice(deviceId);1712          return { ok: false, error: "device_revoked" };1713        }1714        await upsertPairedDeviceV3({1715          deviceId: pending.deviceId,1716          deviceName: pending.deviceName,1717          deviceType:1718            pending.deviceType as import("../shared/sync-types").PairedDevice["deviceType"],1719          longTermPublicKeyHex: pending.longTermPublicKeyHex,1720          signingPublicKeyHex: pending.signingPublicKeyHex,1721          wsPort: pending.wsPort,1722          pairedAt: pending.pairedAt,1723          trustSource: "vouched",1724        });1725        await removePendingVouchedDevice(deviceId);1726        // Newly confirmed vouched device is now a sync target — kick a round1727        // so the mesh introduction pays off without a 10-minute wait.1728        void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);1729        return { ok: true };1730      }1731      case "REJECT_VOUCHED_DEVICE": {1732        const { deviceId } = msg.payload as { deviceId: string };1733        await removePendingVouchedDevice(deviceId);1734        return { ok: true };1735      }1736      case "ROTATE_SYNC_IDENTITY": {1737        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1738        const result = await vault.rotateSyncIdentity();1739        return result.ok ? { ok: true } : { ok: false, error: result.error };1740      }17411742      default:1743        return { ok: false, error: "unknown_message_type" };1744    }1745  } catch (err) {1746    return {1747      ok: false,1748      error: err instanceof Error ? err.message : "operation_failed",1749    };1750  }1751}17521753// Returns username use-counts from the vault, sorted by frequency. Each row1754// also carries the entry's email so the popup can suggest the email/username1755// pair the user has actually used together.1756function buildIdentitySuggestions(1757  entries: VaultEntry[],1758): Array<{ email: string; username: string; count: number }> {1759  const usernameCounts = new Map<string, number>();1760  const usernameEmail = new Map<string, string>();1761  for (const entry of entries) {1762    const username = entry.username ?? "";1763    const email = (entry as VaultEntry & { email?: string }).email ?? "";1764    if (!username && !email) continue;1765    const key = username || email;1766    usernameCounts.set(key, (usernameCounts.get(key) ?? 0) + 1);1767    if (!usernameEmail.has(key)) usernameEmail.set(key, email);1768  }1769  return Array.from(usernameCounts.entries())1770    .sort((a, b) => b[1] - a[1])1771    .map(([username, count]) => ({1772      email: usernameEmail.get(username) ?? "",1773      username,1774      count,1775    }));1776}17771778/** Notify all content scripts that the vault just became available. */1779function broadcastToContentScripts(message: { type: string }): void {1780  if (!chrome.tabs?.query) return;1781  chrome.tabs.query({}, (tabs) => {1782    for (const tab of tabs) {1783      if (tab.id !== undefined) {1784        chrome.tabs.sendMessage(tab.id, message).catch(() => {1785          // Tab may not have a content script — silently ignore1786        });1787      }1788    }1789  });1790}17911792function shouldRefreshAutoLock(type: MessageType): boolean {1793  return (1794    type !== "HAS_VAULT" &&1795    type !== "IS_UNLOCKED" &&1796    type !== "GET_VAULT_STATUS" &&1797    type !== "LOCK" &&1798    type !== "UPDATE_SETTINGS" &&1799    type !== "GET_PENDING_PASSKEY" &&1800    type !== "GET_PASSKEY_PROVIDER_STATE" &&1801    type !== "DECLINE_CARD_SAVE" &&1802    type !== "IS_CARD_DECLINED" &&1803    type !== "AUTOFILL_QUERY" &&1804    type !== "ML_PREDICT_BATCH"1805  );1806}18071808function isAllowedCanaryWebhookUrl(value: string): boolean {1809  try {1810    const url = new URL(value);1811    if (url.protocol !== "https:") return false;18121813    const hostname = url.hostname.toLowerCase();1814    if (1815      hostname === "localhost" ||1816      hostname.endsWith(".localhost") ||1817      isPrivateIpv4(hostname) ||1818      isPrivateIpv6(hostname)1819    ) {1820      return false;1821    }18221823    return true;1824  } catch {1825    return false;1826  }1827}18281829function isPrivateIpv4(hostname: string): boolean {1830  if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {1831    return true;1832  }18331834  const octets = hostname.split(".").map((part) => Number(part));1835  if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {1836    return false;1837  }18381839  return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;1840}18411842function isPrivateIpv6(hostname: string): boolean {1843  const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();1844  // IPv4-mapped IPv6 literals embed a plain IPv4 address that the prefix1845  // list below can never match (WHATWG URL serializes [::ffff:192.168.0.1]1846  // as ::ffff:c0a8:1). Unwrap and test the embedded IPv4; unparseable mapped1847  // tails fail closed. (Codex Security 2026-08-04 LOW,1848  // ssrf.webhook-url-private-ip-filter.) DNS rebinding is out of scope for a1849  // one-shot host check.1850  const mapped = /^::ffff:(.+)$/.exec(normalized);1851  if (mapped) {1852    const tail = mapped[1] ?? "";1853    if (tail.includes(".")) return isPrivateIpv4(tail);1854    const parts = tail.split(":");1855    const [hi, lo] = parts;1856    if (1857      parts.length !== 2 ||1858      !hi ||1859      !lo ||1860      !/^[0-9a-f]{1,4}$/.test(hi) ||1861      !/^[0-9a-f]{1,4}$/.test(lo)1862    ) {1863      return true;1864    }1865    const octets = [1866      (Number.parseInt(hi, 16) >>> 8) & 0xff,1867      Number.parseInt(hi, 16) & 0xff,1868      (Number.parseInt(lo, 16) >>> 8) & 0xff,1869      Number.parseInt(lo, 16) & 0xff,1870    ];1871    return isPrivateIpv4(octets.join("."));1872  }1873  return (1874    normalized === "::1" ||1875    normalized.startsWith("fc") ||1876    normalized.startsWith("fd") ||1877    normalized.startsWith("fe80:")1878  );1879}