123456789101112import { copyToClipboard, getEffectiveClearSeconds } from "./clipboard";13import { getPendingSyncAlerts, clearPendingSyncAlerts } from "./sync-alerts";14import { extractDomain } from "../shared/domain";15import { computeVaultId } from "../shared/sync-identity";16import {17 getPendingVouchedDevice,18 listPendingVouchedDevices,19 removePendingVouchedDevice,20} from "./pending-vouched-devices";21import { isRecoveryConfirmPending } from "./recovery-pending";22import { setupAutoLock, touchAutoLock } from "./alarms";23import { runSelfTest } from "./self-test";24import * as vault from "./vault";25import {26 handleEstimateCodexPages,27 handleGetCodexVaultData,28 handleRestoreFromCodex,29} from "./codex-handlers";30import type { CodexRestorePayload } from "../shared/codex-types";31import {32 activateLicense,33 deactivateLicense,34 getLicenseInfo,35 getLicenseKey,36 isProUser,37 markCelebrationPlayed,38 shouldPlayCelebration,39} from "./license";40import {41 completeFirefoxPasskeyCreate,42 getPasskeyProviderState,43 getPendingPasskeyRequest,44 handleFirefoxPasskeyCreate,45 handleFirefoxPasskeyGet,46 handlePasskeyHasCredentials,47 initiateFirefoxPasskeyCreate,48 isPasskeyOfferDeclined,49 onVaultUnlocked,50 recordPasskeyOfferDecline,51 resolvePendingPasskeyRequest,52 setPasskeyProviderEnabled,53} from "./passkey";54import { buildAdvancedHealthReport } from "./health";55import { storageClear } from "../shared/storage";56import { findInvalidWords } from "../shared/recovery-words";57import type {58 Message,59 MessageResponse,60 MessageType,61 ProFeature,62 VaultEntry,63} from "../shared/types";64import { isPasskeyOnly } from "../shared/types";65import { generateTOTP } from "../crypto/totp";66import { isValidRpId } from "../shared/passkey-utils";67import { isNativePlatform, nativePlugin } from "../shared/native";68import { predictBatch as mlPredictBatch } from "./ml";69import type { MLFieldFeatures } from "../shared/ml";70import {71 buildLocalIdentity,72 getOrCreateDeviceId,73 setDeviceName as setDeviceNameV3,74} from "./device-identity";75import {76 listPairedDevices as listPairedDevicesV3,77 revokeDeviceById as revokeDeviceByIdV3,78 upsertPairedDevice as upsertPairedDeviceV3,79} from "./paired-devices";8081828384858687import {88 cancelRelayAdoptionSource,89 confirmRelayAdoptionSource,90 isRelayAdoptionSourceInFlight,91 startRelayAdoptionSource,92} from "./relay-adoption-source-bg";93import {94 getSyncSettings,95 setRelayUrl,96 setSyncMode,97 type SyncMode,98} from "./sync-settings";99import {100 getSyncState,101 hydratePersistedSyncState,102 setSyncRunner,103 triggerSyncIfReachable,104} from "./sync-trigger";105import { productionSyncRunner } from "./sync-runner";106107108109110111declare const __PEACH_E2E_PASSKEYS__: boolean;112const PASSKEY_E2E_PRO_BYPASS = __PEACH_E2E_PASSKEYS__ === true;113114let nativeSyncRunnerBootstrapped = false;115116function unhexBytes(hex: string): Uint8Array {117 if (hex.length % 2 !== 0) throw new Error("invalid hex");118 const out = new Uint8Array(hex.length / 2);119 for (let i = 0; i < out.length; i++) {120 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);121 }122 return out;123}124125126127128129130131async function requirePro(132 feature: ProFeature,133 trigger: "user" | "background" = "user"134): Promise<{ allowed: boolean }> {135 if (PASSKEY_E2E_PRO_BYPASS && feature === "passkeys") return { allowed: true };136 if (await isProUser()) return { allowed: true };137138 if (trigger === "user") {139 140 chrome.runtime141 .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })142 .catch(() => {143 144 });145 } else {146 147 148 149 if (chrome.storage.session) {150 chrome.storage.session151 .set({ pendingProNotice: { feature, triggeredAt: Date.now() } })152 .catch(() => {});153 }154 }155156 return { allowed: false };157}158159160161162163164165166const FEEDBACK_ENDPOINT = "";167168169170171172const DESTRUCTIVE_MESSAGE_TYPES: ReadonlySet<string> = new Set([173 "DESTROY_VAULT",174 "CLEAR_VAULT_ENTRIES",175 "RESTORE_VAULT_SNAPSHOT",176 "RESTORE_FROM_CODEX",177]);178179180const CANARY_MIN_INTERVAL_MS = 60 * 60 * 1000; 181182const PRIVATE_IPV4_RANGES = [183 { prefix: "10." },184 { prefix: "127." },185 { prefix: "169.254." },186 { prefix: "192.168." },187];188189export function setupMessageHandler(): void {190 chrome.runtime.onMessage.addListener(191 (msg: unknown, sender, sendResponse) => {192 if (!isMessage(msg)) {193 sendResponse({ ok: false, error: "invalid_message" });194 return true;195 }196 handleMessage(msg, sender)197 .then(sendResponse)198 .catch((err) =>199 sendResponse({200 ok: false,201 error: err instanceof Error ? err.message : String(err),202 })203 );204 return true;205 }206 );207208 209 210 211 const reg = (globalThis as any).__peachRegisterMessageHandler;212 if (typeof reg === "function") {213 reg(async (m: unknown) => {214 if (!isMessage(m)) return { ok: false, error: "invalid_message" };215 try {216 return await handleMessage(m);217 } catch (err) {218 return {219 ok: false,220 error: err instanceof Error ? err.message : String(err),221 };222 }223 });224 }225 if (isNativePlatform()) {226 void bootstrapNativeSyncRunner();227 }228}229230async function bootstrapNativeSyncRunner(): Promise<void> {231 if (nativeSyncRunnerBootstrapped) return;232 nativeSyncRunnerBootstrapped = true;233 try {234 setSyncRunner(productionSyncRunner);235 await hydratePersistedSyncState();236 } catch (err) {237 nativeSyncRunnerBootstrapped = false;238 console.warn("[Peach] failed to bootstrap native sync runner:", err);239 }240}241242function isMessage(m: unknown): m is Message {243 return typeof m === "object" && m !== null && "type" in m;244}245246247248249250251252253254function attestedSenderOrigin(sender: Partial<chrome.runtime.MessageSender>): string | undefined {255 if (sender.origin) return sender.origin;256 if (sender.url) {257 try { return new URL(sender.url).origin; } catch { return undefined; }258 }259 return undefined;260}261262263264265266let restoreReady: Promise<void> = Promise.resolve();267268269export function setRestoreReady(p: Promise<unknown>): void {270 restoreReady = p.then(() => undefined, () => undefined);271}272273export async function handleMessage(274 msg: Message,275 sender: Partial<chrome.runtime.MessageSender> = {}276): Promise<MessageResponse> {277 try {278 279 280 281 282 283 284 await restoreReady;285 286 287 288 289 if (290 sender.id !== undefined &&291 sender.id !== chrome.runtime.id292 ) {293 return { ok: false, error: "invalid_sender" };294 }295296 297 298 299 300 301 302 if (DESTRUCTIVE_MESSAGE_TYPES.has(msg.type)) {303 const extensionOrigin = chrome.runtime.getURL("");304 if (!sender.url?.startsWith(extensionOrigin)) {305 return { ok: false, error: "invalid_sender" };306 }307 }308309 if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {310 touchAutoLock();311 }312313 switch (msg.type) {314 case "CREATE_VAULT": {315 const { password } = msg.payload as { password: string };316 317 318 319 320 if (typeof password !== "string" || password.length === 0) {321 return { ok: false, error: "invalid_payload" };322 }323 await vault.createVault(password);324 await setupAutoLock();325 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });326 onVaultUnlocked();327 return { ok: true };328 }329 case "CREATE_VAULT_FROM_ROOT_KEY": {330 const { password, rootKeyHex } = (msg.payload ?? {}) as {331 password?: unknown;332 rootKeyHex?: unknown;333 };334 if (typeof password !== "string" || password.length === 0 || typeof rootKeyHex !== "string") {335 return { ok: false, error: "invalid_payload" };336 }337338 try {339 const { vaultId } = await vault.createVaultFromRootKey(password, rootKeyHex);340 await setupAutoLock();341 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });342 onVaultUnlocked();343 return { ok: true, vaultId };344 } catch (err) {345 return { ok: false, error: err instanceof Error ? err.message : String(err) };346 }347 }348 case "UNLOCK": {349 const { password } = msg.payload as { password: string };350 const ok = await vault.unlock(password);351 if (ok) {352 353 354 const kat = await runSelfTest();355 if (!kat.ok) {356 console.error("[Peach] Crypto self-test FAILED:", kat.error);357 await vault.lock();358 return { ok: false, error: "self_test_failed" };359 }360 await setupAutoLock();361 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });362 onVaultUnlocked();363 if (isNativePlatform()) await publishInMemoryVault(vault.getEntries());364 return { ok: true };365 }366 367 368 369 const failure = vault.consumeLastUnlockFailureReason();370 if (failure) {371 return {372 ok: false,373 error: "vault_corrupted",374 canRestoreFromHistory: true,375 } as MessageResponse;376 }377 return { ok: false };378 }379 case "VALIDATE_RECOVERY_WORDS": {380 const { words } = msg.payload as { words: string[] };381 const invalid = findInvalidWords(words);382 return { ok: true, data: { valid: invalid.length === 0, invalid } };383 }384 case "GET_RECOVERY_WORDS": {385 386 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };387 388 389 390 391 392 393 394 395 if (!(await isRecoveryConfirmPending())) {396 const { password } = (msg.payload ?? {}) as { password?: string };397 if (typeof password !== "string" || !password) {398 return { ok: false, error: "password_required" };399 }400 if (!(await vault.verifyMainPassword(password))) {401 return { ok: false, error: "wrong_password" };402 }403 }404 try {405 const words = await vault.getRecoveryWords();406 return { ok: true, data: words };407 } catch (e) {408 409 return { ok: false, error: "recovery_not_setup" };410 }411 }412 case "RECOVER_WITH_WORDS": {413 414 415 const { words, newPassword } = msg.payload as {416 words: string[];417 newPassword: string;418 };419 const stored = await vault.loadSlotA();420 if (!stored) return { ok: false, error: "no_vault" };421 if (!stored.recovery_salt || !stored.recovery_wrapped_root_key) {422 return { ok: false, error: "recovery_not_setup" };423 }424 425 try {426 await vault.unwrapRootKeyForRecovery(427 stored.recovery_wrapped_root_key,428 stored.recovery_nonce ?? "",429 stored.recovery_tag ?? "",430 stored.recovery_salt,431 words432 );433 } catch {434 return { ok: false, error: "invalid_recovery_words" };435 }436 437 const newSalt = vault.generateSalt();438 const newKey = await vault.deriveKey(newPassword, newSalt);439 440 441 await vault.rewrapRootKeyForPassword(newKey, newSalt);442 443 await vault.changePasswordWithNewKey(newSalt, newKey);444 await setupAutoLock();445 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });446 return { ok: true };447 }448 case "LOCK":449 vault.lock();450 if (isNativePlatform()) await clearInMemoryVault();451 return { ok: true };452 case "IS_UNLOCKED":453 return { ok: true, data: vault.isUnlocked() };454 case "GET_VAULT_STATUS":455 return { ok: true, unlocked: vault.isUnlocked() };456 case "HAS_VAULT":457 return { ok: true, data: await vault.hasVault() };458 case "GET_ENTRIES":459 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };460 return { ok: true, data: vault.getEntries() };461 case "ADD_ENTRY": {462 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };463 const payload = msg.payload;464 if (!payload || typeof payload !== "object" || Array.isArray(payload)) {465 return { ok: false, error: "invalid_payload" };466 }467 const entry = await vault.addEntry(payload as Parameters<typeof vault.addEntry>[0]);468 return { ok: true, data: entry };469 }470 case "BATCH_ADD_ENTRIES": {471 const payload = msg.payload as { entries?: unknown } | null | undefined;472 if (!payload || typeof payload !== "object" || Array.isArray(payload)) {473 return { ok: false, error: "invalid_payload" };474 }475 if (!Array.isArray(payload.entries)) {476 return { ok: false, error: "invalid_payload" };477 }478 const added = await vault.batchAddEntries(payload.entries as Parameters<typeof vault.batchAddEntries>[0]);479 return { ok: true, data: added };480 }481 case "RESTORE_ENTRIES": {482 const payload = msg.payload as483 | { entries?: unknown; mode?: unknown; settings?: unknown }484 | null485 | undefined;486 if (!payload || typeof payload !== "object" || Array.isArray(payload)) {487 return { ok: false, error: "invalid_payload" };488 }489 if (!Array.isArray(payload.entries)) {490 return { ok: false, error: "invalid_payload" };491 }492 if (payload.mode !== "replace" && payload.mode !== "merge") {493 return { ok: false, error: "invalid_payload" };494 }495 const summary = await vault.restoreEntries(496 payload.entries as Parameters<typeof vault.restoreEntries>[0],497 payload.mode498 );499 if (payload.settings && typeof payload.settings === "object" && !Array.isArray(payload.settings)) {500 await vault.updateSettings(payload.settings as vault.VaultSettings);501 }502 return { ok: true, data: summary };503 }504 case "CLEAR_VAULT_ENTRIES": {505 await vault.clearVaultEntries("REPLACE_ALL");506 return { ok: true };507 }508 case "GET_VAULT_HISTORY": {509 const snapshots = await vault.getVaultHistory();510 return { ok: true, data: snapshots };511 }512 case "RESTORE_VAULT_SNAPSHOT": {513 const { snapshotId } = msg.payload as { snapshotId: string };514 const result = await vault.restoreVaultSnapshot(snapshotId);515 if (!result.ok) return { ok: false, error: result.error };516 broadcastToContentScripts({ type: "VAULT_LOCKED" });517 return { ok: true };518 }519 case "COUNT_DUPLICATES": {520 return { ok: true, data: vault.countDuplicates() };521 }522 case "DEDUPLICATE_ENTRIES": {523 const removed = await vault.deduplicateEntries();524 return { ok: true, data: removed };525 }526 case "UPDATE_ENTRY": {527 const payload = msg.payload as { id?: unknown; patch?: unknown } | null | undefined;528 if (!payload || typeof payload !== "object" || Array.isArray(payload)) {529 return { ok: false, error: "invalid_payload" };530 }531 const { id, patch } = payload;532 if (typeof id !== "string" || !id || !patch || typeof patch !== "object" || Array.isArray(patch)) {533 return { ok: false, error: "invalid_payload" };534 }535 536 537 538 539 const incomingCanary = (patch as { canary?: unknown }).canary;540 if (incomingCanary && typeof incomingCanary === "object") {541 const existing = vault.getEntryById(id);542 if (!existing?.canary) {543 const gate = await requirePro("canary_entry");544 if (!gate.allowed) return { ok: false, error: "pro_required" };545 }546 }547 await vault.updateEntry(id, patch as Parameters<typeof vault.updateEntry>[1]);548 return { ok: true };549 }550 case "ADD_ADDITIONAL_URL": {551 const { id, url } = msg.payload as { id: string; url: string };552 const trimmed = typeof url === "string" ? url.trim() : "";553 if (!trimmed) return { ok: false, error: "url_required" };554 const existing = vault.getEntryById(id);555 if (!existing) return { ok: false, error: "entry_not_found" };556 if (existing.type !== "login") return { ok: false, error: "login_entries_only" };557 const merged = [...(existing.additionalUrls ?? []), trimmed];558 await vault.updateEntry(id, { additionalUrls: merged });559 return { ok: true };560 }561 case "DELETE_ENTRY": {562 const { id } = msg.payload as { id: string };563 await vault.deleteEntry(id);564 return { ok: true };565 }566 case "GET_DELETED_ENTRIES":567 return { ok: true, data: vault.getDeletedEntries() };568 case "RESTORE_ENTRY": {569 const { id } = msg.payload as { id: string };570 await vault.restoreEntry(id);571 return { ok: true };572 }573 case "PURGE_ENTRY": {574 const { id } = msg.payload as { id: string };575 await vault.purgeEntry(id);576 return { ok: true };577 }578 case "PURGE_EXPIRED_TRASH": {579 const { retentionMs } = msg.payload as { retentionMs: number };580 const purged = await vault.purgeExpiredTrash(retentionMs);581 return { ok: true, data: purged };582 }583 case "SAVE_NOTE": {584 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };585 const { id, title, body } = msg.payload as { id?: string; title: string; body: string };586 const now = Date.now();587 if (id) {588 const existing = vault.getEntryById(id);589 if (!existing) return { ok: false, error: "not_found" };590 await vault.updateEntry(id, { title: title || "Untitled", notes: body, modifiedAt: now });591 return { ok: true, data: vault.getEntryById(id) };592 } else {593 594 595 596 const entry = await vault.addEntry({597 type: "note",598 title: title || "Untitled",599 notes: body,600 });601 return { ok: true, data: entry };602 }603 }604 case "DELETE_NOTE": {605 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };606 const { id } = msg.payload as { id: string };607 await vault.deleteEntry(id);608 return { ok: true };609 }610 case "GET_NOTES": {611 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };612 const notes = vault.getEntries().filter(613 (e) => e.type === "note" && !e.deletedAt614 );615 return { ok: true, data: notes } as MessageResponse;616 }617 case "SEARCH_VAULT": {618 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };619 620 621 const { query } = (msg.payload ?? {}) as { query?: unknown };622 if (typeof query !== "string") {623 return { ok: false, error: "invalid_payload" };624 }625 const q = query.toLowerCase();626 const searchResults = vault.getEntries().filter(627 (e) =>628 !e.deletedAt &&629 (e.title.toLowerCase().includes(q) ||630 e.notes?.toLowerCase().includes(q) ||631 e.username?.toLowerCase().includes(q))632 );633 return { ok: true, data: searchResults } as MessageResponse;634 }635 case "AUTOFILL_QUERY": {636 if (!vault.isUnlocked()) {637 return { ok: false, error: "vault_locked" };638 }639 let { includeAll } = msg.payload as { includeAll?: boolean };640 const { domain, kind, userGesture } = msg.payload as {641 domain: string;642 kind?: "login" | "card" | "totp";643 userGesture?: boolean;644 };645 646 647 648 649 650 651 652 if (includeAll && userGesture !== true) {653 includeAll = false;654 }655 656 657 658 659 if (kind === "card") {660 const cards = vault.getEntries().filter((e) => e.type === "card");661 return {662 ok: true,663 data: cards.map((e) => ({664 id: e.id,665 title: e.cardNickname || e.title,666 cardholderName: e.cardholderName,667 last4: e.last4,668 expMonth: e.expMonth,669 expYear: e.expYear,670 network: e.network,671 })),672 };673 }674 if (kind === "totp") {675 const matches = (676 includeAll677 ? vault.getEntries().filter((entry) => entry.type !== "note" && entry.type !== "card")678 : vault.queryDomain(domain)679 ).filter((entry) => !isPasskeyOnly(entry) && Boolean(entry.totp));680 return {681 ok: true,682 data: matches.map((e) => ({683 id: e.id,684 title: e.title,685 username: e.username,686 email: e.email,687 url: e.url,688 hasTotp: true,689 })),690 };691 }692 693 694 695 696 697 const matches = (698 includeAll699 ? vault700 .getEntries()701 .filter((entry) => entry.type !== "note" && entry.type !== "card")702 : vault.queryDomain(domain)703 ).filter((entry) => !isPasskeyOnly(entry));704 return {705 ok: true,706 data: matches.map((e) =>707 includeAll708 ? {709 id: e.id,710 title: e.title,711 712 713 714 715 716 username: e.username,717 email: e.email,718 url: e.url,719 hasPasskey: e.passkey !== undefined,720 }721 : {722 id: e.id,723 title: e.title,724 username: e.username,725 email: e.email,726 url: e.url,727 hasTotp: Boolean(e.totp),728 hasPasskey: e.passkey !== undefined,729 }730 ),731 };732 }733 case "GET_ENTRY_FOR_FILL": {734 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };735 const { id, explicitSummon } = msg.payload as {736 id: string;737 explicitSummon?: boolean;738 };739 const entry = vault.getEntryById(id);740 if (!entry) return { ok: false, error: "entry_not_found" };741 742 743 744 if (isPasskeyOnly(entry)) return { ok: false, error: "passkey_not_fillable" };745746 747 748 749 750 if (sender.tab !== undefined || sender.url !== undefined) {751 let senderDomain: string | null = null;752 try {753 const rawUrl = sender.tab?.url ?? sender.url ?? "";754 senderDomain = rawUrl ? extractDomain(new URL(rawUrl).hostname) : null;755 } catch {756 senderDomain = null;757 }758 if (!senderDomain) return { ok: false, error: "origin_unverified" };759760 const entryDomains = new Set<string>();761 const primary = extractDomain(entry.url ?? "");762 if (primary) entryDomains.add(primary);763 for (const extra of entry.additionalUrls ?? []) {764 const d = extractDomain(extra);765 if (d) entryDomains.add(d);766 }767768 769 770 771 if (explicitSummon !== true && !entryDomains.has(senderDomain)) {772 773 return { ok: false, error: "entry_not_found" };774 }775 }776 777 778 779 780 781 782 783 784 785 786 const {787 passkey: _strippedPasskey,788 totp: seed,789 totpAlgorithm,790 totpDigits,791 totpPeriod,792 passwordHistory: _ph,793 ...safeEntry794 } = entry;795 let totpCode: string | undefined;796 let totpRemaining: number | undefined;797 if (seed) {798 try {799 800 801 const generated = generateTOTP(seed, totpAlgorithm, totpDigits, totpPeriod);802 totpCode = generated.code;803 totpRemaining = generated.remaining;804 } catch {805 totpCode = undefined;806 }807 }808 return { ok: true, data: { ...safeEntry, totpCode, totpRemaining } };809 }810 811 812 813 814 815 816 case "GET_ENTRY_FOR_EDIT": {817 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };818 const { id, explicitSummon } = msg.payload as {819 id: string;820 explicitSummon?: boolean;821 };822 const entry = vault.getEntryById(id);823 if (!entry) return { ok: false, error: "entry_not_found" };824825 826 827 828 829 830 831 832 833 if (sender.tab !== undefined || sender.url !== undefined) {834 let senderDomain: string | null = null;835 try {836 const rawUrl = sender.tab?.url ?? sender.url ?? "";837 senderDomain = rawUrl ? extractDomain(new URL(rawUrl).hostname) : null;838 } catch {839 senderDomain = null;840 }841 if (senderDomain) {842 const entryDomains = new Set<string>();843 const primary = extractDomain(entry.url ?? "");844 if (primary) entryDomains.add(primary);845 for (const extra of entry.additionalUrls ?? []) {846 const d = extractDomain(extra);847 if (d) entryDomains.add(d);848 }849 if (explicitSummon !== true && !entryDomains.has(senderDomain)) {850 return { ok: false, error: "entry_not_found" }; 851 }852 }853 }854855 const { passkey: _passkey, ...safeEntry } = entry;856 return { ok: true, data: safeEntry };857 }858 case "COPY": {859 860 861 862 const { text, label, alreadyWritten } = (msg.payload ?? {}) as {863 text?: unknown;864 label?: unknown;865 alreadyWritten?: unknown;866 };867 if (typeof text !== "string" || typeof label !== "string") {868 return { ok: false, error: "invalid_payload" };869 }870 await copyToClipboard(text, label, Boolean(alreadyWritten));871 return { ok: true, clearSeconds: getEffectiveClearSeconds() };872 }873 case "CHECK_ALL_BREACHES": {874 const allEntries = vault.getEntries();875 const results: Record<string, number> = {};876 for (const entry of allEntries) {877 if (entry.type === "note") continue;878 if (!entry.password) continue;879 results[entry.id] = await vault.checkBreach(entry.password);880 881 await new Promise((r) => setTimeout(r, 200));882 }883 884 for (const [id, count] of Object.entries(results)) {885 const entry = vault.getEntryById(id);886 if (entry && (entry.breach ?? 0) !== count) {887 await vault.updateEntry(id, { breach: count > 0 ? count : undefined });888 }889 }890 return { ok: true, data: results };891 }892 case "GET_SETTINGS":893 return { ok: true, data: await vault.ensureSettingsLoaded() };894 case "UPDATE_SETTINGS": {895 const patch = msg.payload;896 if (!patch || typeof patch !== "object" || Array.isArray(patch)) {897 return { ok: false, error: "invalid_payload" };898 }899 await vault.updateSettings(patch as vault.VaultSettings);900 if (typeof (patch as Record<string, unknown>).autoLockMinutes === "number") {901 await setupAutoLock();902 if (vault.isUnlocked()) touchAutoLock();903 }904 return { ok: true };905 }906 case "GET_PENDING_PASSKEY":907 return { ok: true, data: await getPendingPasskeyRequest() };908 case "APPROVE_PASSKEY": {909 const { requestId } = msg.payload as { requestId: number };910 await resolvePendingPasskeyRequest(requestId, true);911 return { ok: true };912 }913 case "DENY_PASSKEY": {914 const { requestId } = msg.payload as { requestId: number };915 await resolvePendingPasskeyRequest(requestId, false);916 return { ok: true };917 }918 case "GET_PASSKEY_PROVIDER_STATE":919 return { ok: true, data: await getPasskeyProviderState() };920 case "SET_PASSKEY_PROVIDER_ENABLED": {921 const { enabled } = msg.payload as { enabled: boolean };922 923 924 if (enabled) {925 const gate = await requirePro("passkeys");926 if (!gate.allowed) return { ok: false, error: "pro_required" };927 }928 return { ok: true, data: await setPasskeyProviderEnabled(enabled) };929 }930 931 932 933 934 935 936 case "FIRE_CANARY_PING": {937 938 939 940 941 const { webhookUrl, entryTitle, trigger } = msg.payload as {942 webhookUrl: string;943 entryTitle: string;944 trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";945 };946 if (!isAllowedCanaryWebhookUrl(webhookUrl)) {947 return { ok: false, error: "invalid_webhook_url" };948 }949 950 951 952 953 954 955 956 if (trigger !== "test") {957 const rlKey = `canary-last-fetch:${webhookUrl}:${entryTitle}`;958 const stored = await chrome.storage.local.get(rlKey);959 const last = (stored as Record<string, unknown>)[rlKey];960 if (typeof last === "number" && Date.now() - last < CANARY_MIN_INTERVAL_MS) {961 return { ok: true, data: { rateLimited: true } };962 }963 await chrome.storage.local.set({ [rlKey]: Date.now() });964 }965 const body = JSON.stringify({966 event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",967 entry: entryTitle,968 trigger,969 timestamp: new Date().toISOString(),970 source: "peach-extension",971 });972 if (trigger === "test") {973 974 975 976 977 978 try {979 const res = await fetch(webhookUrl, {980 method: "POST",981 headers: { "Content-Type": "application/json" },982 body,983 });984 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };985 } catch (err) {986 return { ok: false, error: err instanceof Error ? err.message : "network_error" };987 }988 }989 990 fetch(webhookUrl, {991 method: "POST",992 headers: { "Content-Type": "application/json" },993 body,994 }).catch(() => {});995 return { ok: true };996 }997 case "ACTIVATE_LICENSE": {998 const { key } = msg.payload as { key: string };999 const licenseInfo = await activateLicense(key);1000 return { ok: true, data: licenseInfo };1001 }1002 case "GET_LICENSE": {1003 const licenseInfo = await getLicenseInfo();1004 return { ok: true, data: licenseInfo };1005 }1006 case "GET_LICENSE_KEY": {1007 const key = await getLicenseKey();1008 return { ok: true, data: key };1009 }1010 case "DEACTIVATE_LICENSE": {1011 await deactivateLicense();1012 return { ok: true };1013 }1014 case "SHOULD_PLAY_CELEBRATION": {1015 const should = await shouldPlayCelebration();1016 return { ok: true, data: should };1017 }1018 case "MARK_CELEBRATION_PLAYED": {1019 await markCelebrationPlayed();1020 return { ok: true };1021 }1022 case "GET_CODEX_VAULT_DATA": {1023 1024 1025 const gate = await requirePro("codex");1026 if (!gate.allowed) return { ok: false, error: "pro_required" };1027 return await handleGetCodexVaultData();1028 }1029 case "ESTIMATE_CODEX_PAGES": {1030 const gate = await requirePro("codex");1031 if (!gate.allowed) return { ok: false, error: "pro_required" };1032 return await handleEstimateCodexPages();1033 }1034 case "RESTORE_FROM_CODEX": {1035 1036 1037 1038 const payload = msg.payload as CodexRestorePayload;1039 return await handleRestoreFromCodex(payload);1040 }1041 case "OPEN_PRO_GATE": {1042 1043 const { feature } = (msg.payload ?? {}) as { feature?: string };1044 if (chrome.storage.session) {1045 await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });1046 }1047 try { await chrome.action.openPopup(); } catch { }1048 return { ok: true };1049 }1050 case "OPEN_POPUP": {1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 if (chrome.storage.session) {1061 await chrome.storage.session.set({ peach_inline_unlock: true });1062 }1063 try { await chrome.action.openPopup(); } catch { }1064 return { ok: true };1065 }1066 case "GET_IDENTITIES": {1067 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1068 return { ok: true, data: vault.getIdentities() };1069 }1070 case "TOUCH_AUTO_LOCK": {1071 1072 1073 return { ok: true };1074 }1075 case "GET_ADVANCED_HEALTH_REPORT": {1076 const gate = await requirePro("advanced_health");1077 if (!gate.allowed) return { ok: false, error: "pro_required" };1078 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1079 const report = await buildAdvancedHealthReport(vault.getEntries());1080 return { ok: true, data: report };1081 }1082 case "IS_DURESS_ENABLED": {1083 const enabled = await vault.isDuressEnabled();1084 return { ok: true, data: enabled };1085 }1086 case "SETUP_DURESS": {1087 1088 1089 1090 1091 const gate = await requirePro("duress_vault");1092 if (!gate.allowed) return { ok: false, error: "pro_required" };1093 1094 1095 1096 if (!vault.isUnlocked()) return { ok: false, error: "session_expired" };1097 const { duressPassword } = msg.payload as { duressPassword: string };1098 await vault.setupDuress(duressPassword);1099 return { ok: true };1100 }1101 case "DISABLE_DURESS": {1102 await vault.disableDuress();1103 return { ok: true };1104 }1105 case "VERIFY_MAIN_PASSWORD": {1106 const { password } = msg.payload as { password: string };1107 const ok = await vault.verifyMainPassword(password);1108 return { ok: true, data: ok };1109 }1110 case "CHANGE_PASSWORD": {1111 const { currentPassword, newPassword } = msg.payload as {1112 currentPassword: string;1113 newPassword: string;1114 };1115 const changed = await vault.changePassword(currentPassword, newPassword);1116 return changed1117 ? { ok: true }1118 : { ok: false, error: "incorrect_current_password" };1119 }1120 case "GET_SITE_PASSWORD_POLICY": {1121 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });1122 if (!tab?.id) return { ok: true, data: null };1123 try {1124 const response = await chrome.tabs.sendMessage(tab.id, {1125 type: "GET_SITE_PASSWORD_POLICY",1126 });1127 return (response as MessageResponse) ?? { ok: true, data: null };1128 } catch {1129 return { ok: true, data: null };1130 }1131 }1132 case "RECORD_MANUAL_SUMMON": {1133 const { domain } = msg.payload as { domain: string };1134 const settings = vault.getSettings();1135 const existing = settings.manualSummonDomains?.[domain];1136 const now = Date.now();1137 await vault.updateSettings({1138 manualSummonDomains: {1139 ...settings.manualSummonDomains,1140 [domain]: {1141 firstSummonedAt: existing?.firstSummonedAt ?? now,1142 summonCount: (existing?.summonCount ?? 0) + 1,1143 lastSummonedAt: now,1144 },1145 },1146 });1147 return { ok: true };1148 }1149 case "DECLINE_CARD_SAVE": {1150 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1151 await vault.declineCardSaveOffer(last4, expMonth, expYear);1152 return { ok: true };1153 }1154 case "IS_CARD_DECLINED": {1155 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1156 const declined = await vault.isCardDeclined(last4, expMonth, expYear);1157 return { ok: true, data: declined };1158 }1159 case "INITIATE_FIREFOX_PASSKEY_CREATE": {1160 const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {1161 rpId?: string;1162 rpName?: string;1163 userName?: string;1164 userDisplayName?: string;1165 };1166 if (!rpId || !userName) return { ok: false, error: "rpid_and_username_required" };1167 return await initiateFirefoxPasskeyCreate({1168 rpId,1169 rpName: rpName ?? rpId,1170 userName,1171 userDisplayName,1172 });1173 }11741175 case "COMPLETE_FIREFOX_PASSKEY_CREATE": {1176 const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;1177 if (!payload) return { ok: false, error: "missing_payload" };1178 return await completeFirefoxPasskeyCreate(payload);1179 }11801181 case "PASSKEY_OFFER_DECLINE": {1182 const { domain } = (msg.payload ?? {}) as { domain?: string };1183 if (!domain) return { ok: false, error: "domain_required" };1184 await recordPasskeyOfferDecline(domain);1185 return { ok: true };1186 }11871188 case "GET_PASSKEY_OFFER_DECLINED": {1189 const { domain } = (msg.payload ?? {}) as { domain?: string };1190 if (!domain) return { ok: false, error: "domain_required" };1191 return { ok: true, data: await isPasskeyOfferDeclined(domain) };1192 }11931194 case "PASSKEY_HAS_CREDENTIALS": {1195 const payload = msg.payload as {1196 rpId?: string;1197 allowCredentials?: Array<{ id: string; type?: string }>;1198 } | undefined;1199 if (!payload?.rpId) return { ok: false, error: "rpid_required" };1200 return await handlePasskeyHasCredentials({1201 rpId: payload.rpId,1202 allowCredentials: payload.allowCredentials ?? [],1203 }, attestedSenderOrigin(sender));1204 }12051206 case "PASSKEY_CREATE": {1207 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;1208 if (!payload?.rpId || !payload?.userName) {1209 return { ok: false, error: "rpid_and_username_required" };1210 }1211 return await handleFirefoxPasskeyCreate(payload, attestedSenderOrigin(sender));1212 }12131214 case "PASSKEY_GET": {1215 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;1216 if (!payload?.rpId) return { ok: false, error: "rpid_required" };1217 return await handleFirefoxPasskeyGet(payload, attestedSenderOrigin(sender));1218 }12191220 case "PASSKEY_AUTH_RESPONSE": {1221 const { requestId, approved } = (msg.payload ?? {}) as {1222 requestId?: string | number;1223 approved?: boolean;1224 };1225 if (requestId === undefined || requestId === null || typeof approved !== 'boolean') {1226 return { ok: false, error: 'requestId and approved required' };1227 }1228 await resolvePendingPasskeyRequest(Number(requestId), approved);1229 return { ok: true };1230 }12311232 case "GET_PASSKEY_FOR_DOMAIN": {1233 const { domain } = (msg.payload ?? {}) as { domain?: string };1234 if (!domain) return { ok: false, error: "domain_required" };1235 1236 1237 1238 const origin = attestedSenderOrigin(sender);1239 if (!origin || !isValidRpId(domain, origin)) return { ok: true, data: null };1240 if (!vault.isUnlocked()) return { ok: true, data: null };1241 const match = vault1242 .getEntries()1243 .find((entry) => entry.passkey?.rpId === domain);1244 if (!match || !match.passkey) return { ok: true, data: null };1245 1246 1247 return {1248 ok: true,1249 data: {1250 rpId: match.passkey.rpId,1251 userName: match.passkey.userName,1252 browser: match.passkey.browser,1253 },1254 };1255 }12561257 case "DESTROY_VAULT": {1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 const preserved = await chrome.storage.local.get([1269 "peach_license",1270 "peach_pro_celebrated",1271 ]);1272 vault.lock();1273 await storageClear();1274 await vault.clearVaultHistory();1275 const toRestore: Record<string, unknown> = {};1276 if (preserved.peach_license !== undefined) toRestore.peach_license = preserved.peach_license;1277 if (preserved.peach_pro_celebrated !== undefined) toRestore.peach_pro_celebrated = preserved.peach_pro_celebrated;1278 if (Object.keys(toRestore).length > 0) {1279 await chrome.storage.local.set(toRestore);1280 }1281 if (chrome.storage.session) {1282 await chrome.storage.session.clear();1283 }1284 return { ok: true };1285 }12861287 case "SUBMIT_FEEDBACK": {1288 const { category, message, version, browser } = msg.payload as {1289 category: string;1290 message: string;1291 version: string;1292 browser: string;1293 };1294 if (!message.trim()) return { ok: false, error: "message_empty" };1295 if (!FEEDBACK_ENDPOINT) return { ok: true };1296 if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {1297 return { ok: false, error: "invalid_feedback_endpoint" };1298 }1299 try {1300 const res = await fetch(FEEDBACK_ENDPOINT, {1301 method: "POST",1302 headers: { "Content-Type": "application/json" },1303 body: JSON.stringify({ category, message, version, browser, source: "peach-extension" }),1304 });1305 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };1306 } catch (err) {1307 return { ok: false, error: err instanceof Error ? err.message : "network_error" };1308 }1309 }1310 1311 case "UNLOCK_WITH_KEY": {1312 const { masterKeyHex } = msg.payload as { masterKeyHex: string };1313 if (!isNativePlatform()) {1314 return { ok: false, error: "desktop_only_uses_password" };1315 }1316 try {1317 const result = await vault.unlockWithKey(masterKeyHex);1318 if (result.ok) {1319 onVaultUnlocked();1320 await publishInMemoryVault(result.entries);1321 }1322 return { ok: result.ok, data: result.ok ? { entries: result.entries } : undefined, error: result.ok ? undefined : result.error };1323 } catch {1324 return { ok: false, error: "invalid_key" };1325 }1326 }1327 1328 case "GET_DERIVED_KEY": {1329 if (!isNativePlatform()) {1330 return { ok: false, error: "android_only" };1331 }1332 if (!vault.isUnlocked()) {1333 return { ok: false, error: "vault_locked" };1334 }1335 return { ok: true, data: { masterKeyHex: vault.getDerivedKeyHex() } };1336 }1337 case "GET_IDENTITY_SUGGESTIONS": {1338 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1339 const entries = vault.getEntries();1340 const suggestions = buildIdentitySuggestions(entries);1341 return { ok: true, data: { suggestions } };1342 }1343 case "POPULATE_INLINE_VAULT": {1344 if (!isNativePlatform()) return { ok: false };1345 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1346 const entries = vault.getEntries();1347 await publishInMemoryVault(entries);1348 return { ok: true };1349 }1350 case "ML_INIT":1351 return { ok: true };13521353 case "ML_PREDICT_BATCH": {1354 1355 const { featuresArray } = (msg.payload ?? {}) as { featuresArray?: unknown };1356 if (!Array.isArray(featuresArray)) {1357 return { ok: false, error: "invalid_payload" };1358 }1359 return { ok: true, data: mlPredictBatch(featuresArray as MLFieldFeatures[]) };1360 }13611362 1363 1364 1365 1366 case "GET_LOCAL_IDENTITY": {1367 1368 1369 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1370 1371 1372 const masterKeyHex = vault.getRootKeyHex();1373 if (!masterKeyHex) return { ok: false, error: "vault_locked" };1374 const masterKey = unhexBytes(masterKeyHex);1375 try {1376 const identity = await buildLocalIdentity(masterKey);1377 1378 1379 if (await isProUser()) {1380 const license = await getLicenseKey();1381 if (license) (identity as { license?: string }).license = license;1382 }1383 return { ok: true, data: identity };1384 } finally {1385 masterKey.fill(0);1386 }1387 }13881389 case "GET_ADOPTION_MATERIAL": {1390 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1391 const rootKeyHex = vault.getRootKeyHex();1392 if (!rootKeyHex) return { ok: false, error: "vault_locked" };1393 const rootKeyBytes = unhexBytes(rootKeyHex);1394 try {1395 const data: { rootKeyHex: string; vaultId: string; license?: string } = {1396 rootKeyHex,1397 vaultId: await computeVaultId(rootKeyBytes),1398 };1399 if (await isProUser()) {1400 const license = await getLicenseKey();1401 if (license) data.license = license;1402 }1403 return { ok: true, data };1404 } finally {1405 rootKeyBytes.fill(0);1406 }1407 }14081409 case "COMPLETE_PAIRING": {1410 1411 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1412 const paired = (msg.payload as { paired?: unknown })?.paired;1413 if (typeof paired !== "object" || paired === null) {1414 return { ok: false, error: "invalid_payload" };1415 }1416 const candidate = paired as Record<string, unknown>;1417 if (1418 typeof candidate.deviceId !== "string" || !candidate.deviceId ||1419 typeof candidate.deviceName !== "string" || !candidate.deviceName ||1420 typeof candidate.deviceType !== "string" ||1421 typeof candidate.longTermPublicKeyHex !== "string" ||1422 !/^[0-9a-f]{64}$/.test(candidate.longTermPublicKeyHex) ||1423 (1424 candidate.signingPublicKeyHex !== undefined &&1425 (1426 typeof candidate.signingPublicKeyHex !== "string" ||1427 !/^[0-9a-f]{64}$/.test(candidate.signingPublicKeyHex)1428 )1429 ) ||1430 typeof candidate.pairedAt !== "number"1431 ) {1432 return { ok: false, error: "invalid_paired_device" };1433 }1434 await upsertPairedDeviceV3(candidate as unknown as import("../shared/sync-types").PairedDevice);1435 return { ok: true };1436 }14371438 case "LIST_PAIRED_DEVICES": {1439 const devices = await listPairedDevicesV3();1440 return { ok: true, data: { devices } };1441 }14421443 case "UNPAIR_DEVICE": {1444 const { deviceId } = (msg.payload ?? {}) as { deviceId?: string };1445 if (typeof deviceId !== "string" || !deviceId) {1446 return { ok: false, error: "invalid_payload" };1447 }1448 1449 1450 1451 await revokeDeviceByIdV3(deviceId, await getOrCreateDeviceId());1452 return { ok: true };1453 }14541455 case "RENAME_LOCAL_DEVICE": {1456 const { name } = (msg.payload ?? {}) as { name?: string };1457 if (typeof name !== "string") return { ok: false, error: "invalid_payload" };1458 await setDeviceNameV3(name);1459 return { ok: true };1460 }14611462 1463 1464 1465 1466 case "START_ADOPTION_SOURCE": {1467 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1468 if (isRelayAdoptionSourceInFlight()) {1469 return { ok: false, error: "adoption_source_in_progress" };1470 }1471 try {1472 return { ok: true, data: await startRelayAdoptionSource() };1473 } catch (error) {1474 return {1475 ok: false,1476 error: error instanceof Error ? error.message : "adoption_source_failed",1477 };1478 }1479 }14801481 case "ADOPTION_SOURCE_CONFIRM": {1482 const { password, cancel } = (msg.payload ?? {}) as {1483 password?: unknown;1484 cancel?: unknown;1485 };1486 if (cancel === true) {1487 return {1488 ok: true,1489 data: await confirmRelayAdoptionSource(null),1490 };1491 }1492 if (typeof password !== "string" || password.length === 0) {1493 return { ok: false, error: "invalid_payload" };1494 }1495 return {1496 ok: true,1497 data: await confirmRelayAdoptionSource(password),1498 };1499 }15001501 case "CANCEL_ADOPTION_SOURCE": {1502 cancelRelayAdoptionSource();1503 return { ok: true };1504 }15051506 case "TRIGGER_SYNC": {1507 const payload = (msg.payload ?? {}) as {1508 reason?: "vault_change" | "foreground" | "manual";1509 force?: boolean;1510 };1511 const reason = payload.reason ?? "foreground";15121513 void triggerSyncIfReachable({ reason, force: payload.force === true });1514 return { ok: true };1515 }15161517 case "GET_SYNC_STATE": {1518 return { ok: true, data: getSyncState() };1519 }15201521 case "GET_SYNC_SETTINGS": {1522 return { ok: true, data: await getSyncSettings() };1523 }15241525 case "SET_SYNC_SETTINGS": {1526 const payload = (msg.payload ?? {}) as {1527 mode?: unknown;1528 relayUrl?: unknown;1529 };1530 try {1531 1532 1533 if (payload.relayUrl !== undefined) {1534 if (typeof payload.relayUrl !== "string") {1535 return { ok: false, error: "invalid_relay_url" };1536 }1537 return { ok: true, data: await setRelayUrl(payload.relayUrl) };1538 }1539 if (payload.mode !== undefined) {1540 if (payload.mode !== "public" && payload.mode !== "private" && payload.mode !== "off") {1541 return { ok: false, error: "invalid_sync_mode" };1542 }1543 return { ok: true, data: await setSyncMode(payload.mode as SyncMode) };1544 }1545 return { ok: true, data: await getSyncSettings() };1546 } catch (error) {1547 return {1548 ok: false,1549 error: error instanceof Error ? error.message : "invalid_relay_url",1550 };1551 }1552 }15531554 case "GET_SYNC_ALERTS": {1555 const { consume } = (msg.payload ?? {}) as { consume?: boolean };1556 const alerts = await getPendingSyncAlerts();1557 1558 1559 1560 if (consume) await clearPendingSyncAlerts();1561 return { ok: true, data: alerts };1562 }15631564 case "GET_PENDING_VOUCHED_DEVICES": {1565 return { ok: true, data: await listPendingVouchedDevices() };1566 }1567 case "CONFIRM_VOUCHED_DEVICE": {1568 const { deviceId } = msg.payload as { deviceId: string };1569 const pending = await getPendingVouchedDevice(deviceId);1570 if (!pending) return { ok: false, error: "not_pending" };1571 await upsertPairedDeviceV3({1572 deviceId: pending.deviceId,1573 deviceName: pending.deviceName,1574 deviceType:1575 pending.deviceType as import("../shared/sync-types").PairedDevice["deviceType"],1576 longTermPublicKeyHex: pending.longTermPublicKeyHex,1577 signingPublicKeyHex: pending.signingPublicKeyHex,1578 wsPort: pending.wsPort,1579 pairedAt: pending.pairedAt,1580 trustSource: "vouched",1581 });1582 await removePendingVouchedDevice(deviceId);1583 return { ok: true };1584 }1585 case "REJECT_VOUCHED_DEVICE": {1586 const { deviceId } = msg.payload as { deviceId: string };1587 await removePendingVouchedDevice(deviceId);1588 return { ok: true };1589 }1590 case "ROTATE_SYNC_IDENTITY": {1591 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1592 const result = await vault.rotateSyncIdentity();1593 return result.ok ? { ok: true } : { ok: false, error: result.error };1594 }15951596 default:1597 return { ok: false, error: "unknown_message_type" };1598 }1599 } catch (err) {1600 return {1601 ok: false,1602 error: err instanceof Error ? err.message : "operation_failed",1603 };1604 }1605}16061607160816091610async function publishInMemoryVault(entries: VaultEntry[]): Promise<void> {1611 const plugin = nativePlugin<{1612 setEntries?: (args: {1613 entries: Array<{1614 id: string;1615 title: string;1616 username: string;1617 email: string;1618 password: string;1619 url: string;1620 totp: string;1621 additionalUrls: string[];1622 }>;1623 }) => Promise<void>;1624 }>("InMemoryVaultPlugin");1625 if (!plugin?.setEntries) return;1626 await plugin.setEntries({1627 entries: entries.map((e) => ({1628 id: e.id,1629 title: e.title ?? "",1630 username: e.username ?? "",1631 email: (e as VaultEntry & { email?: string }).email ?? "",1632 password: e.password ?? "",1633 url: e.url ?? "",1634 totp: (e as VaultEntry & { totp?: string }).totp ?? "",1635 additionalUrls: (e as VaultEntry & { additionalUrls?: string[] }).additionalUrls ?? [],1636 })),1637 });1638}163916401641async function clearInMemoryVault(): Promise<void> {1642 const plugin = nativePlugin<{ clearEntries?: () => Promise<void> }>("InMemoryVaultPlugin");1643 if (!plugin?.clearEntries) return;1644 await plugin.clearEntries();1645}16461647164816491650function buildIdentitySuggestions(1651 entries: VaultEntry[],1652): Array<{ email: string; username: string; count: number }> {1653 const usernameCounts = new Map<string, number>();1654 const usernameEmail = new Map<string, string>();1655 for (const entry of entries) {1656 const username = entry.username ?? "";1657 const email = (entry as VaultEntry & { email?: string }).email ?? "";1658 if (!username && !email) continue;1659 const key = username || email;1660 usernameCounts.set(key, (usernameCounts.get(key) ?? 0) + 1);1661 if (!usernameEmail.has(key)) usernameEmail.set(key, email);1662 }1663 return Array.from(usernameCounts.entries())1664 .sort((a, b) => b[1] - a[1])1665 .map(([username, count]) => ({1666 email: usernameEmail.get(username) ?? "",1667 username,1668 count,1669 }));1670}167116721673function broadcastToContentScripts(message: { type: string }): void {1674 if (!chrome.tabs?.query) return;1675 chrome.tabs.query({}, (tabs) => {1676 for (const tab of tabs) {1677 if (tab.id !== undefined) {1678 chrome.tabs.sendMessage(tab.id, message).catch(() => {1679 1680 });1681 }1682 }1683 });1684}16851686function shouldRefreshAutoLock(type: MessageType): boolean {1687 return (1688 type !== "HAS_VAULT" &&1689 type !== "IS_UNLOCKED" &&1690 type !== "GET_VAULT_STATUS" &&1691 type !== "LOCK" &&1692 type !== "UPDATE_SETTINGS" &&1693 type !== "GET_PENDING_PASSKEY" &&1694 type !== "GET_PASSKEY_PROVIDER_STATE" &&1695 type !== "DECLINE_CARD_SAVE" &&1696 type !== "IS_CARD_DECLINED" &&1697 type !== "SUBMIT_FEEDBACK"1698 );1699}17001701function isAllowedCanaryWebhookUrl(value: string): boolean {1702 try {1703 const url = new URL(value);1704 if (url.protocol !== "https:") return false;17051706 const hostname = url.hostname.toLowerCase();1707 if (1708 hostname === "localhost" ||1709 hostname.endsWith(".localhost") ||1710 isPrivateIpv4(hostname) ||1711 isPrivateIpv6(hostname)1712 ) {1713 return false;1714 }17151716 return true;1717 } catch {1718 return false;1719 }1720}17211722function isPrivateIpv4(hostname: string): boolean {1723 if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {1724 return true;1725 }17261727 const octets = hostname.split(".").map((part) => Number(part));1728 if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {1729 return false;1730 }17311732 return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;1733}17341735function isPrivateIpv6(hostname: string): boolean {1736 const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();1737 return (1738 normalized === "::1" ||1739 normalized.startsWith("fc") ||1740 normalized.startsWith("fd") ||1741 normalized.startsWith("fe80:")1742 );1743}