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 clearDeviceRevocation,77 isDeviceRevoked,78 listPairedDevices as listPairedDevicesV3,79 revokeDeviceById as revokeDeviceByIdV3,80 upsertPairedDevice as upsertPairedDeviceV3,81} from "./paired-devices";8283848586878889import {90 cancelRelayAdoptionSource,91 isRelayAdoptionSourceInFlight,92 resolveSourceSasConfirmation,93 startRelayAdoptionReceiver,94 startRelayAdoptionSource,95} from "./relay-adoption-bg";96import {97 getSyncSettings,98 setRelayUrl,99 setSyncMode,100 type SyncMode,101} from "./sync-settings";102import {103 getSyncState,104 hydratePersistedSyncState,105 setSyncRunner,106 triggerSyncIfReachable,107} from "./sync-trigger";108import { productionSyncRunner } from "./sync-runner";109110111112113114declare const __PEACH_E2E_PASSKEYS__: boolean;115const PASSKEY_E2E_PRO_BYPASS = __PEACH_E2E_PASSKEYS__ === true;116117let nativeSyncRunnerBootstrapped = false;118119function unhexBytes(hex: string): Uint8Array {120 if (hex.length % 2 !== 0) throw new Error("invalid hex");121 const out = new Uint8Array(hex.length / 2);122 for (let i = 0; i < out.length; i++) {123 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);124 }125 return out;126}127128129130131132133134async function requirePro(135 feature: ProFeature,136 trigger: "user" | "background" = "user"137): Promise<{ allowed: boolean }> {138 if (PASSKEY_E2E_PRO_BYPASS && feature === "passkeys") return { allowed: true };139 if (await isProUser()) return { allowed: true };140141 if (trigger === "user") {142 143 chrome.runtime144 .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })145 .catch(() => {146 147 });148 } else {149 150 151 152 if (chrome.storage.session) {153 chrome.storage.session154 .set({ pendingProNotice: { feature, triggeredAt: Date.now() } })155 .catch(() => {});156 }157 }158159 return { allowed: false };160}161162163164165166167168169170171172173174175176const PRIVILEGED_MESSAGE_TYPES: ReadonlySet<string> = new Set([177 "DESTROY_VAULT",178 "CLEAR_VAULT_ENTRIES",179 "RESTORE_VAULT_SNAPSHOT",180 "RESTORE_FROM_CODEX",181 "GET_ADOPTION_MATERIAL",182 "COMPLETE_PAIRING",183 "COMPLETE_FIREFOX_PASSKEY_CREATE",184]);185186187const CANARY_MIN_INTERVAL_MS = 60 * 60 * 1000; 188189const PRIVATE_IPV4_RANGES = [190 { prefix: "10." },191 { prefix: "127." },192 { prefix: "169.254." },193 { prefix: "192.168." },194];195196export function setupMessageHandler(): void {197 chrome.runtime.onMessage.addListener(198 (msg: unknown, sender, sendResponse) => {199 if (!isMessage(msg)) {200 sendResponse({ ok: false, error: "invalid_message" });201 return true;202 }203 handleMessage(msg, sender)204 .then(sendResponse)205 .catch((err) =>206 sendResponse({207 ok: false,208 error: err instanceof Error ? err.message : String(err),209 })210 );211 return true;212 }213 );214215 216 217 218 const reg = (globalThis as any).__peachRegisterMessageHandler;219 if (typeof reg === "function") {220 reg(async (m: unknown) => {221 if (!isMessage(m)) return { ok: false, error: "invalid_message" };222 try {223 return await handleMessage(m);224 } catch (err) {225 return {226 ok: false,227 error: err instanceof Error ? err.message : String(err),228 };229 }230 });231 }232 if (isNativePlatform()) {233 void bootstrapNativeSyncRunner();234 }235}236237async function bootstrapNativeSyncRunner(): Promise<void> {238 if (nativeSyncRunnerBootstrapped) return;239 nativeSyncRunnerBootstrapped = true;240 try {241 setSyncRunner(productionSyncRunner);242 await hydratePersistedSyncState();243 } catch (err) {244 nativeSyncRunnerBootstrapped = false;245 console.warn("[Peach] failed to bootstrap native sync runner:", err);246 }247}248249function isMessage(m: unknown): m is Message {250 return typeof m === "object" && m !== null && "type" in m;251}252253254255256257258259260261function attestedSenderOrigin(sender: Partial<chrome.runtime.MessageSender>): string | undefined {262 if (sender.origin) return sender.origin;263 if (sender.url) {264 try { return new URL(sender.url).origin; } catch { return undefined; }265 }266 return undefined;267}268269270271272273let restoreReady: Promise<void> = Promise.resolve();274275276export function setRestoreReady(p: Promise<unknown>): void {277 restoreReady = p.then(() => undefined, () => undefined);278}279280export async function handleMessage(281 msg: Message,282 sender: Partial<chrome.runtime.MessageSender> = {}283): Promise<MessageResponse> {284 try {285 286 287 288 289 290 291 await restoreReady;292 293 294 295 296 if (297 sender.id !== undefined &&298 sender.id !== chrome.runtime.id299 ) {300 return { ok: false, error: "invalid_sender" };301 }302303 304 305 306 307 308 309 310 311 if (PRIVILEGED_MESSAGE_TYPES.has(msg.type)) {312 const extensionOrigin = chrome.runtime.getURL("");313 if (!sender.url?.startsWith(extensionOrigin)) {314 return { ok: false, error: "invalid_sender" };315 }316 }317318 if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {319 touchAutoLock();320 }321322 switch (msg.type) {323 case "CREATE_VAULT": {324 const { password } = msg.payload as { password: string };325 326 327 328 329 if (typeof password !== "string" || password.length === 0) {330 return { ok: false, error: "invalid_payload" };331 }332 await vault.createVault(password);333 await setupAutoLock();334 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });335 onVaultUnlocked();336 return { ok: true };337 }338 case "CREATE_VAULT_FROM_ROOT_KEY": {339 console.log("[Peach diag] CREATE_VAULT_FROM_ROOT_KEY handler entered");340 const { password, rootKeyHex } = (msg.payload ?? {}) as {341 password?: unknown;342 rootKeyHex?: unknown;343 };344 console.log("[Peach diag] payload types:", typeof password, typeof rootKeyHex, "lengths:", (password as string)?.length, (rootKeyHex as string)?.length);345 if (typeof password !== "string" || password.length === 0 || typeof rootKeyHex !== "string") {346 console.log("[Peach diag] payload invalid, returning invalid_payload");347 return { ok: false, error: "invalid_payload" };348 }349350 try {351 console.log("[Peach diag] calling vault.createVaultFromRootKey");352 const { vaultId } = await vault.createVaultFromRootKey(password, rootKeyHex);353 console.log("[Peach diag] createVaultFromRootKey returned vaultId", vaultId);354 await setupAutoLock();355 console.log("[Peach diag] setupAutoLock done");356 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });357 onVaultUnlocked();358 console.log("[Peach diag] handler completing successfully");359 return { ok: true, vaultId };360 } catch (err) {361 console.error("[Peach diag] handler caught error:", err);362 return { ok: false, error: err instanceof Error ? err.message : String(err) };363 }364 }365 case "UNLOCK": {366 const { password } = msg.payload as { password: string };367 const ok = await vault.unlock(password);368 if (ok) {369 370 371 const kat = await runSelfTest();372 if (!kat.ok) {373 console.error("[Peach] Crypto self-test FAILED:", kat.error);374 await vault.lock();375 return { ok: false, error: "self_test_failed" };376 }377 await setupAutoLock();378 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });379 onVaultUnlocked();380 if (isNativePlatform()) await publishInMemoryVault(vault.getEntries());381 return { ok: true };382 }383 384 385 386 const failure = vault.consumeLastUnlockFailureReason();387 if (failure) {388 return {389 ok: false,390 error: "vault_corrupted",391 canRestoreFromHistory: true,392 } as MessageResponse;393 }394 return { ok: false };395 }396 case "VALIDATE_RECOVERY_WORDS": {397 const { words } = msg.payload as { words: string[] };398 const invalid = findInvalidWords(words);399 return { ok: true, data: { valid: invalid.length === 0, invalid } };400 }401 case "GET_RECOVERY_WORDS": {402 403 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };404 405 406 407 408 409 410 411 412 if (!(await isRecoveryConfirmPending())) {413 const { password } = (msg.payload ?? {}) as { password?: string };414 if (typeof password !== "string" || !password) {415 return { ok: false, error: "password_required" };416 }417 if (!(await vault.verifyMainPassword(password))) {418 return { ok: false, error: "wrong_password" };419 }420 }421 try {422 const words = await vault.getRecoveryWords();423 return { ok: true, data: words };424 } catch (e) {425 426 return { ok: false, error: "recovery_not_setup" };427 }428 }429 case "RECOVER_WITH_WORDS": {430 431 432 const { words, newPassword } = msg.payload as {433 words: string[];434 newPassword: string;435 };436 const stored = await vault.loadSlotA();437 if (!stored) return { ok: false, error: "no_vault" };438 if (!stored.recovery_salt || !stored.recovery_wrapped_root_key) {439 return { ok: false, error: "recovery_not_setup" };440 }441 442 try {443 await vault.unwrapRootKeyForRecovery(444 stored.recovery_wrapped_root_key,445 stored.recovery_nonce ?? "",446 stored.recovery_tag ?? "",447 stored.recovery_salt,448 words449 );450 } catch {451 return { ok: false, error: "invalid_recovery_words" };452 }453 454 const newSalt = vault.generateSalt();455 const newKey = await vault.deriveKey(newPassword, newSalt);456 457 458 await vault.rewrapRootKeyForPassword(newKey, newSalt);459 460 await vault.changePasswordWithNewKey(newSalt, newKey);461 await setupAutoLock();462 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });463 return { ok: true };464 }465 case "LOCK":466 vault.lock();467 if (isNativePlatform()) await clearInMemoryVault();468 return { ok: true };469 case "IS_UNLOCKED":470 return { ok: true, data: vault.isUnlocked() };471 case "GET_VAULT_STATUS":472 return { ok: true, unlocked: vault.isUnlocked() };473 case "HAS_VAULT":474 return { ok: true, data: await vault.hasVault() };475 case "GET_ENTRIES":476 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };477 return { ok: true, data: vault.getEntries() };478 case "ADD_ENTRY": {479 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };480 const payload = msg.payload;481 if (!payload || typeof payload !== "object" || Array.isArray(payload)) {482 return { ok: false, error: "invalid_payload" };483 }484 const entry = await vault.addEntry(payload as Parameters<typeof vault.addEntry>[0]);485 return { ok: true, data: entry };486 }487 case "BATCH_ADD_ENTRIES": {488 const payload = msg.payload as { entries?: unknown } | null | undefined;489 if (!payload || typeof payload !== "object" || Array.isArray(payload)) {490 return { ok: false, error: "invalid_payload" };491 }492 if (!Array.isArray(payload.entries)) {493 return { ok: false, error: "invalid_payload" };494 }495 const added = await vault.batchAddEntries(payload.entries as Parameters<typeof vault.batchAddEntries>[0]);496 return { ok: true, data: added };497 }498 case "RESTORE_ENTRIES": {499 const payload = msg.payload as500 | { entries?: unknown; mode?: unknown; settings?: unknown }501 | null502 | undefined;503 if (!payload || typeof payload !== "object" || Array.isArray(payload)) {504 return { ok: false, error: "invalid_payload" };505 }506 if (!Array.isArray(payload.entries)) {507 return { ok: false, error: "invalid_payload" };508 }509 if (payload.mode !== "replace" && payload.mode !== "merge") {510 return { ok: false, error: "invalid_payload" };511 }512 const summary = await vault.restoreEntries(513 payload.entries as Parameters<typeof vault.restoreEntries>[0],514 payload.mode515 );516 if (payload.settings && typeof payload.settings === "object" && !Array.isArray(payload.settings)) {517 await vault.updateSettings(payload.settings as vault.VaultSettings);518 }519 return { ok: true, data: summary };520 }521 case "CLEAR_VAULT_ENTRIES": {522 await vault.clearVaultEntries("REPLACE_ALL");523 return { ok: true };524 }525 case "GET_VAULT_HISTORY": {526 const snapshots = await vault.getVaultHistory();527 return { ok: true, data: snapshots };528 }529 case "RESTORE_VAULT_SNAPSHOT": {530 const { snapshotId } = msg.payload as { snapshotId: string };531 const result = await vault.restoreVaultSnapshot(snapshotId);532 if (!result.ok) return { ok: false, error: result.error };533 broadcastToContentScripts({ type: "VAULT_LOCKED" });534 return { ok: true };535 }536 case "COUNT_DUPLICATES": {537 return { ok: true, data: vault.countDuplicates() };538 }539 case "DEDUPLICATE_ENTRIES": {540 const removed = await vault.deduplicateEntries();541 return { ok: true, data: removed };542 }543 case "UPDATE_ENTRY": {544 const payload = msg.payload as { id?: unknown; patch?: unknown } | null | undefined;545 if (!payload || typeof payload !== "object" || Array.isArray(payload)) {546 return { ok: false, error: "invalid_payload" };547 }548 const { id, patch } = payload;549 if (typeof id !== "string" || !id || !patch || typeof patch !== "object" || Array.isArray(patch)) {550 return { ok: false, error: "invalid_payload" };551 }552 553 554 555 556 const incomingCanary = (patch as { canary?: unknown }).canary;557 if (incomingCanary && typeof incomingCanary === "object") {558 const existing = vault.getEntryById(id);559 if (!existing?.canary) {560 const gate = await requirePro("canary_entry");561 if (!gate.allowed) return { ok: false, error: "pro_required" };562 }563 }564 await vault.updateEntry(id, patch as Parameters<typeof vault.updateEntry>[1]);565 return { ok: true };566 }567 case "ADD_ADDITIONAL_URL": {568 const { id, url } = msg.payload as { id: string; url: string };569 const trimmed = typeof url === "string" ? url.trim() : "";570 if (!trimmed) return { ok: false, error: "url_required" };571 const existing = vault.getEntryById(id);572 if (!existing) return { ok: false, error: "entry_not_found" };573 if (existing.type !== "login") return { ok: false, error: "login_entries_only" };574 const merged = [...(existing.additionalUrls ?? []), trimmed];575 await vault.updateEntry(id, { additionalUrls: merged });576 return { ok: true };577 }578 case "DELETE_ENTRY": {579 const { id } = msg.payload as { id: string };580 await vault.deleteEntry(id);581 return { ok: true };582 }583 case "GET_DELETED_ENTRIES":584 return { ok: true, data: vault.getDeletedEntries() };585 case "RESTORE_ENTRY": {586 const { id } = msg.payload as { id: string };587 await vault.restoreEntry(id);588 return { ok: true };589 }590 case "PURGE_ENTRY": {591 const { id } = msg.payload as { id: string };592 await vault.purgeEntry(id);593 return { ok: true };594 }595 case "PURGE_EXPIRED_TRASH": {596 const { retentionMs } = msg.payload as { retentionMs: number };597 const purged = await vault.purgeExpiredTrash(retentionMs);598 return { ok: true, data: purged };599 }600 case "SAVE_NOTE": {601 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };602 const { id, title, body } = msg.payload as { id?: string; title: string; body: string };603 const now = Date.now();604 if (id) {605 const existing = vault.getEntryById(id);606 if (!existing) return { ok: false, error: "not_found" };607 await vault.updateEntry(id, { title: title || "Untitled", notes: body, modifiedAt: now });608 return { ok: true, data: vault.getEntryById(id) };609 } else {610 611 612 613 const entry = await vault.addEntry({614 type: "note",615 title: title || "Untitled",616 notes: body,617 });618 return { ok: true, data: entry };619 }620 }621 case "DELETE_NOTE": {622 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };623 const { id } = msg.payload as { id: string };624 await vault.deleteEntry(id);625 return { ok: true };626 }627 case "GET_NOTES": {628 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };629 const notes = vault.getEntries().filter(630 (e) => e.type === "note" && !e.deletedAt631 );632 return { ok: true, data: notes } as MessageResponse;633 }634 case "SEARCH_VAULT": {635 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };636 637 638 const { query } = (msg.payload ?? {}) as { query?: unknown };639 if (typeof query !== "string") {640 return { ok: false, error: "invalid_payload" };641 }642 const q = query.toLowerCase();643 const searchResults = vault.getEntries().filter(644 (e) =>645 !e.deletedAt &&646 (e.title.toLowerCase().includes(q) ||647 e.notes?.toLowerCase().includes(q) ||648 e.username?.toLowerCase().includes(q))649 );650 return { ok: true, data: searchResults } as MessageResponse;651 }652 case "AUTOFILL_QUERY": {653 if (!vault.isUnlocked()) {654 return { ok: false, error: "vault_locked" };655 }656 let { includeAll } = msg.payload as { includeAll?: boolean };657 const { domain, kind, userGesture } = msg.payload as {658 domain: string;659 kind?: "login" | "card" | "totp";660 userGesture?: boolean;661 };662 663 664 665 666 667 668 669 if (includeAll && userGesture !== true) {670 includeAll = false;671 }672 673 674 675 676 if (kind === "card") {677 const cards = vault.getEntries().filter((e) => e.type === "card");678 return {679 ok: true,680 data: cards.map((e) => ({681 id: e.id,682 title: e.cardNickname || e.title,683 cardholderName: e.cardholderName,684 last4: e.last4,685 expMonth: e.expMonth,686 expYear: e.expYear,687 network: e.network,688 })),689 };690 }691 if (kind === "totp") {692 const matches = (693 includeAll694 ? vault.getEntries().filter((entry) => entry.type !== "note" && entry.type !== "card")695 : vault.queryDomain(domain)696 ).filter((entry) => !isPasskeyOnly(entry) && Boolean(entry.totp));697 return {698 ok: true,699 data: matches.map((e) => ({700 id: e.id,701 title: e.title,702 username: e.username,703 email: e.email,704 url: e.url,705 hasTotp: true,706 })),707 };708 }709 710 711 712 713 714 const matches = (715 includeAll716 ? vault717 .getEntries()718 .filter((entry) => entry.type !== "note" && entry.type !== "card")719 : vault.queryDomain(domain)720 ).filter((entry) => !isPasskeyOnly(entry));721 return {722 ok: true,723 data: matches.map((e) =>724 includeAll725 ? {726 id: e.id,727 title: e.title,728 729 730 731 732 733 username: e.username,734 email: e.email,735 url: e.url,736 hasPasskey: e.passkey !== undefined,737 }738 : {739 id: e.id,740 title: e.title,741 username: e.username,742 email: e.email,743 url: e.url,744 hasTotp: Boolean(e.totp),745 hasPasskey: e.passkey !== undefined,746 }747 ),748 };749 }750 case "GET_ENTRY_FOR_FILL": {751 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };752 const { id, explicitSummon } = msg.payload as {753 id: string;754 explicitSummon?: boolean;755 };756 const entry = vault.getEntryById(id);757 if (!entry) return { ok: false, error: "entry_not_found" };758 759 760 761 if (isPasskeyOnly(entry)) return { ok: false, error: "passkey_not_fillable" };762763 764 765 766 767 if (sender.tab !== undefined || sender.url !== undefined) {768 let senderDomain: string | null = null;769 try {770 const rawUrl = sender.tab?.url ?? sender.url ?? "";771 senderDomain = rawUrl ? extractDomain(new URL(rawUrl).hostname) : null;772 } catch {773 senderDomain = null;774 }775 if (!senderDomain) return { ok: false, error: "origin_unverified" };776777 const entryDomains = new Set<string>();778 const primary = extractDomain(entry.url ?? "");779 if (primary) entryDomains.add(primary);780 for (const extra of entry.additionalUrls ?? []) {781 const d = extractDomain(extra);782 if (d) entryDomains.add(d);783 }784785 786 787 788 if (explicitSummon !== true && !entryDomains.has(senderDomain)) {789 790 return { ok: false, error: "entry_not_found" };791 }792 }793 794 795 796 797 798 799 800 801 802 803 const {804 passkey: _strippedPasskey,805 totp: seed,806 totpAlgorithm,807 totpDigits,808 totpPeriod,809 passwordHistory: _ph,810 ...safeEntry811 } = entry;812 let totpCode: string | undefined;813 let totpRemaining: number | undefined;814 if (seed) {815 try {816 817 818 const generated = generateTOTP(seed, totpAlgorithm, totpDigits, totpPeriod);819 totpCode = generated.code;820 totpRemaining = generated.remaining;821 } catch {822 totpCode = undefined;823 }824 }825 return { ok: true, data: { ...safeEntry, totpCode, totpRemaining } };826 }827 828 829 830 831 832 833 case "GET_ENTRY_FOR_EDIT": {834 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };835 const { id, explicitSummon } = msg.payload as {836 id: string;837 explicitSummon?: boolean;838 };839 const entry = vault.getEntryById(id);840 if (!entry) return { ok: false, error: "entry_not_found" };841842 843 844 845 846 847 848 849 850 if (sender.tab !== undefined || sender.url !== undefined) {851 let senderDomain: string | null = null;852 try {853 const rawUrl = sender.tab?.url ?? sender.url ?? "";854 senderDomain = rawUrl ? extractDomain(new URL(rawUrl).hostname) : null;855 } catch {856 senderDomain = null;857 }858 if (senderDomain) {859 const entryDomains = new Set<string>();860 const primary = extractDomain(entry.url ?? "");861 if (primary) entryDomains.add(primary);862 for (const extra of entry.additionalUrls ?? []) {863 const d = extractDomain(extra);864 if (d) entryDomains.add(d);865 }866 if (explicitSummon !== true && !entryDomains.has(senderDomain)) {867 return { ok: false, error: "entry_not_found" }; 868 }869 }870 }871872 const { passkey: _passkey, ...safeEntry } = entry;873 return { ok: true, data: safeEntry };874 }875 case "COPY": {876 877 878 879 const { text, label, alreadyWritten } = (msg.payload ?? {}) as {880 text?: unknown;881 label?: unknown;882 alreadyWritten?: unknown;883 };884 if (typeof text !== "string" || typeof label !== "string") {885 return { ok: false, error: "invalid_payload" };886 }887 await copyToClipboard(text, label, Boolean(alreadyWritten));888 return { ok: true, clearSeconds: getEffectiveClearSeconds() };889 }890 case "CHECK_ALL_BREACHES": {891 const allEntries = vault.getEntries();892 const results: Record<string, number> = {};893 for (const entry of allEntries) {894 if (entry.type === "note") continue;895 if (!entry.password) continue;896 results[entry.id] = await vault.checkBreach(entry.password);897 898 await new Promise((r) => setTimeout(r, 200));899 }900 901 for (const [id, count] of Object.entries(results)) {902 const entry = vault.getEntryById(id);903 if (entry && (entry.breach ?? 0) !== count) {904 await vault.updateEntry(id, { breach: count > 0 ? count : undefined });905 }906 }907 return { ok: true, data: results };908 }909 case "GET_SETTINGS":910 return { ok: true, data: await vault.ensureSettingsLoaded() };911 case "UPDATE_SETTINGS": {912 const patch = msg.payload;913 if (!patch || typeof patch !== "object" || Array.isArray(patch)) {914 return { ok: false, error: "invalid_payload" };915 }916 await vault.updateSettings(patch as vault.VaultSettings);917 if (typeof (patch as Record<string, unknown>).autoLockMinutes === "number") {918 await setupAutoLock();919 if (vault.isUnlocked()) touchAutoLock();920 }921 return { ok: true };922 }923 case "GET_PENDING_PASSKEY":924 return { ok: true, data: await getPendingPasskeyRequest() };925 case "APPROVE_PASSKEY": {926 const { requestId } = msg.payload as { requestId: number };927 await resolvePendingPasskeyRequest(requestId, true);928 return { ok: true };929 }930 case "DENY_PASSKEY": {931 const { requestId } = msg.payload as { requestId: number };932 await resolvePendingPasskeyRequest(requestId, false);933 return { ok: true };934 }935 case "GET_PASSKEY_PROVIDER_STATE":936 return { ok: true, data: await getPasskeyProviderState() };937 case "SET_PASSKEY_PROVIDER_ENABLED": {938 const { enabled } = msg.payload as { enabled: boolean };939 940 941 if (enabled) {942 const gate = await requirePro("passkeys");943 if (!gate.allowed) return { ok: false, error: "pro_required" };944 }945 return { ok: true, data: await setPasskeyProviderEnabled(enabled) };946 }947 948 949 950 951 952 953 case "FIRE_CANARY_PING": {954 955 956 957 958 const { webhookUrl, entryTitle, trigger } = msg.payload as {959 webhookUrl: string;960 entryTitle: string;961 trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";962 };963 if (!isAllowedCanaryWebhookUrl(webhookUrl)) {964 return { ok: false, error: "invalid_webhook_url" };965 }966 967 968 969 970 971 972 973 if (trigger !== "test") {974 const rlKey = `canary-last-fetch:${webhookUrl}:${entryTitle}`;975 const stored = await chrome.storage.local.get(rlKey);976 const last = (stored as Record<string, unknown>)[rlKey];977 if (typeof last === "number" && Date.now() - last < CANARY_MIN_INTERVAL_MS) {978 return { ok: true, data: { rateLimited: true } };979 }980 await chrome.storage.local.set({ [rlKey]: Date.now() });981 }982 const body = JSON.stringify({983 event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",984 entry: entryTitle,985 trigger,986 timestamp: new Date().toISOString(),987 source: "peach-extension",988 });989 if (trigger === "test") {990 991 992 993 994 995 try {996 const res = await fetch(webhookUrl, {997 method: "POST",998 headers: { "Content-Type": "application/json" },999 body,1000 });1001 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };1002 } catch (err) {1003 return { ok: false, error: err instanceof Error ? err.message : "network_error" };1004 }1005 }1006 1007 fetch(webhookUrl, {1008 method: "POST",1009 headers: { "Content-Type": "application/json" },1010 body,1011 }).catch(() => {});1012 return { ok: true };1013 }1014 case "ACTIVATE_LICENSE": {1015 const { key } = msg.payload as { key: string };1016 const licenseInfo = await activateLicense(key);1017 return { ok: true, data: licenseInfo };1018 }1019 case "GET_LICENSE": {1020 const licenseInfo = await getLicenseInfo();1021 return { ok: true, data: licenseInfo };1022 }1023 case "GET_LICENSE_KEY": {1024 const key = await getLicenseKey();1025 return { ok: true, data: key };1026 }1027 case "DEACTIVATE_LICENSE": {1028 await deactivateLicense();1029 return { ok: true };1030 }1031 case "SHOULD_PLAY_CELEBRATION": {1032 const should = await shouldPlayCelebration();1033 return { ok: true, data: should };1034 }1035 case "MARK_CELEBRATION_PLAYED": {1036 await markCelebrationPlayed();1037 return { ok: true };1038 }1039 case "GET_CODEX_VAULT_DATA": {1040 1041 1042 const gate = await requirePro("codex");1043 if (!gate.allowed) return { ok: false, error: "pro_required" };1044 return await handleGetCodexVaultData();1045 }1046 case "ESTIMATE_CODEX_PAGES": {1047 const gate = await requirePro("codex");1048 if (!gate.allowed) return { ok: false, error: "pro_required" };1049 return await handleEstimateCodexPages();1050 }1051 case "RESTORE_FROM_CODEX": {1052 1053 1054 1055 const payload = msg.payload as CodexRestorePayload;1056 return await handleRestoreFromCodex(payload);1057 }1058 case "OPEN_PRO_GATE": {1059 1060 const { feature } = (msg.payload ?? {}) as { feature?: string };1061 if (chrome.storage.session) {1062 await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });1063 }1064 try { await chrome.action.openPopup(); } catch { }1065 return { ok: true };1066 }1067 case "OPEN_POPUP": {1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 if (chrome.storage.session) {1078 await chrome.storage.session.set({ peach_inline_unlock: true });1079 }1080 try { await chrome.action.openPopup(); } catch { }1081 return { ok: true };1082 }1083 case "GET_IDENTITIES": {1084 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1085 return { ok: true, data: vault.getIdentities() };1086 }1087 case "TOUCH_AUTO_LOCK": {1088 1089 1090 return { ok: true };1091 }1092 case "GET_ADVANCED_HEALTH_REPORT": {1093 const gate = await requirePro("advanced_health");1094 if (!gate.allowed) return { ok: false, error: "pro_required" };1095 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1096 const report = await buildAdvancedHealthReport(vault.getEntries());1097 return { ok: true, data: report };1098 }1099 case "IS_DURESS_ENABLED": {1100 const enabled = await vault.isDuressEnabled();1101 return { ok: true, data: enabled };1102 }1103 case "SETUP_DURESS": {1104 1105 1106 1107 1108 const gate = await requirePro("duress_vault");1109 if (!gate.allowed) return { ok: false, error: "pro_required" };1110 1111 1112 1113 if (!vault.isUnlocked()) return { ok: false, error: "session_expired" };1114 const { duressPassword } = msg.payload as { duressPassword: string };1115 await vault.setupDuress(duressPassword);1116 return { ok: true };1117 }1118 case "DISABLE_DURESS": {1119 await vault.disableDuress();1120 return { ok: true };1121 }1122 case "VERIFY_MAIN_PASSWORD": {1123 const { password } = msg.payload as { password: string };1124 const ok = await vault.verifyMainPassword(password);1125 return { ok: true, data: ok };1126 }1127 case "CHANGE_PASSWORD": {1128 const { currentPassword, newPassword } = msg.payload as {1129 currentPassword: string;1130 newPassword: string;1131 };1132 const changed = await vault.changePassword(currentPassword, newPassword);1133 return changed1134 ? { ok: true }1135 : { ok: false, error: "incorrect_current_password" };1136 }1137 case "GET_SITE_PASSWORD_POLICY": {1138 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });1139 if (!tab?.id) return { ok: true, data: null };1140 try {1141 const response = await chrome.tabs.sendMessage(tab.id, {1142 type: "GET_SITE_PASSWORD_POLICY",1143 });1144 return (response as MessageResponse) ?? { ok: true, data: null };1145 } catch {1146 return { ok: true, data: null };1147 }1148 }1149 case "RECORD_MANUAL_SUMMON": {1150 const { domain } = msg.payload as { domain: string };1151 const settings = vault.getSettings();1152 const existing = settings.manualSummonDomains?.[domain];1153 const now = Date.now();1154 await vault.updateSettings({1155 manualSummonDomains: {1156 ...settings.manualSummonDomains,1157 [domain]: {1158 firstSummonedAt: existing?.firstSummonedAt ?? now,1159 summonCount: (existing?.summonCount ?? 0) + 1,1160 lastSummonedAt: now,1161 },1162 },1163 });1164 return { ok: true };1165 }1166 case "DECLINE_CARD_SAVE": {1167 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1168 await vault.declineCardSaveOffer(last4, expMonth, expYear);1169 return { ok: true };1170 }1171 case "IS_CARD_DECLINED": {1172 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1173 const declined = await vault.isCardDeclined(last4, expMonth, expYear);1174 return { ok: true, data: declined };1175 }1176 case "INITIATE_FIREFOX_PASSKEY_CREATE": {1177 const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {1178 rpId?: string;1179 rpName?: string;1180 userName?: string;1181 userDisplayName?: string;1182 };1183 if (!rpId || !userName) return { ok: false, error: "rpid_and_username_required" };1184 return await initiateFirefoxPasskeyCreate({1185 rpId,1186 rpName: rpName ?? rpId,1187 userName,1188 userDisplayName,1189 }, attestedSenderOrigin(sender));1190 }11911192 case "COMPLETE_FIREFOX_PASSKEY_CREATE": {1193 const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;1194 if (!payload) return { ok: false, error: "missing_payload" };1195 return await completeFirefoxPasskeyCreate(payload);1196 }11971198 case "PASSKEY_OFFER_DECLINE": {1199 const { domain } = (msg.payload ?? {}) as { domain?: string };1200 if (!domain) return { ok: false, error: "domain_required" };1201 await recordPasskeyOfferDecline(domain);1202 return { ok: true };1203 }12041205 case "GET_PASSKEY_OFFER_DECLINED": {1206 const { domain } = (msg.payload ?? {}) as { domain?: string };1207 if (!domain) return { ok: false, error: "domain_required" };1208 return { ok: true, data: await isPasskeyOfferDeclined(domain) };1209 }12101211 case "PASSKEY_HAS_CREDENTIALS": {1212 const payload = msg.payload as {1213 rpId?: string;1214 allowCredentials?: Array<{ id: string; type?: string }>;1215 } | undefined;1216 if (!payload?.rpId) return { ok: false, error: "rpid_required" };1217 return await handlePasskeyHasCredentials({1218 rpId: payload.rpId,1219 allowCredentials: payload.allowCredentials ?? [],1220 }, attestedSenderOrigin(sender));1221 }12221223 case "PASSKEY_CREATE": {1224 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;1225 if (!payload?.rpId || !payload?.userName) {1226 return { ok: false, error: "rpid_and_username_required" };1227 }1228 return await handleFirefoxPasskeyCreate(payload, attestedSenderOrigin(sender));1229 }12301231 case "PASSKEY_GET": {1232 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;1233 if (!payload?.rpId) return { ok: false, error: "rpid_required" };1234 return await handleFirefoxPasskeyGet(payload, attestedSenderOrigin(sender));1235 }12361237 case "PASSKEY_AUTH_RESPONSE": {1238 const { requestId, approved } = (msg.payload ?? {}) as {1239 requestId?: string | number;1240 approved?: boolean;1241 };1242 if (requestId === undefined || requestId === null || typeof approved !== 'boolean') {1243 return { ok: false, error: 'requestId and approved required' };1244 }1245 await resolvePendingPasskeyRequest(Number(requestId), approved);1246 return { ok: true };1247 }12481249 case "GET_PASSKEY_FOR_DOMAIN": {1250 const { domain } = (msg.payload ?? {}) as { domain?: string };1251 if (!domain) return { ok: false, error: "domain_required" };1252 1253 1254 1255 const origin = attestedSenderOrigin(sender);1256 if (!origin || !isValidRpId(domain, origin)) return { ok: true, data: null };1257 if (!vault.isUnlocked()) return { ok: true, data: null };1258 const match = vault1259 .getEntries()1260 .find((entry) => entry.passkey?.rpId === domain);1261 if (!match || !match.passkey) return { ok: true, data: null };1262 1263 1264 return {1265 ok: true,1266 data: {1267 rpId: match.passkey.rpId,1268 userName: match.passkey.userName,1269 browser: match.passkey.browser,1270 },1271 };1272 }12731274 case "DESTROY_VAULT": {1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 const preserved = await chrome.storage.local.get([1286 "peach_license",1287 "peach_pro_celebrated",1288 ]);1289 vault.lock();1290 await storageClear();1291 await vault.clearVaultHistory();1292 const toRestore: Record<string, unknown> = {};1293 if (preserved.peach_license !== undefined) toRestore.peach_license = preserved.peach_license;1294 if (preserved.peach_pro_celebrated !== undefined) toRestore.peach_pro_celebrated = preserved.peach_pro_celebrated;1295 if (Object.keys(toRestore).length > 0) {1296 await chrome.storage.local.set(toRestore);1297 }1298 if (chrome.storage.session) {1299 await chrome.storage.session.clear();1300 }1301 return { ok: true };1302 }13031304 1305 case "UNLOCK_WITH_KEY": {1306 const { masterKeyHex } = msg.payload as { masterKeyHex: string };1307 if (!isNativePlatform()) {1308 return { ok: false, error: "desktop_only_uses_password" };1309 }1310 try {1311 const result = await vault.unlockWithKey(masterKeyHex);1312 if (result.ok) {1313 onVaultUnlocked();1314 await publishInMemoryVault(result.entries);1315 }1316 return { ok: result.ok, data: result.ok ? { entries: result.entries } : undefined, error: result.ok ? undefined : result.error };1317 } catch {1318 return { ok: false, error: "invalid_key" };1319 }1320 }1321 1322 case "GET_DERIVED_KEY": {1323 if (!isNativePlatform()) {1324 return { ok: false, error: "android_only" };1325 }1326 if (!vault.isUnlocked()) {1327 return { ok: false, error: "vault_locked" };1328 }1329 return { ok: true, data: { masterKeyHex: vault.getDerivedKeyHex() } };1330 }1331 case "GET_IDENTITY_SUGGESTIONS": {1332 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1333 const entries = vault.getEntries();1334 const suggestions = buildIdentitySuggestions(entries);1335 return { ok: true, data: { suggestions } };1336 }1337 case "POPULATE_INLINE_VAULT": {1338 if (!isNativePlatform()) return { ok: false };1339 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1340 const entries = vault.getEntries();1341 await publishInMemoryVault(entries);1342 return { ok: true };1343 }1344 case "ML_INIT":1345 return { ok: true };13461347 case "ML_PREDICT_BATCH": {1348 1349 const { featuresArray } = (msg.payload ?? {}) as { featuresArray?: unknown };1350 if (!Array.isArray(featuresArray)) {1351 return { ok: false, error: "invalid_payload" };1352 }1353 return { ok: true, data: mlPredictBatch(featuresArray as MLFieldFeatures[]) };1354 }13551356 1357 1358 1359 1360 case "GET_LOCAL_IDENTITY": {1361 1362 1363 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1364 1365 1366 const masterKeyHex = vault.getRootKeyHex();1367 if (!masterKeyHex) return { ok: false, error: "vault_locked" };1368 const masterKey = unhexBytes(masterKeyHex);1369 try {1370 const identity = await buildLocalIdentity(masterKey);1371 1372 1373 if (await isProUser()) {1374 const license = await getLicenseKey();1375 if (license) (identity as { license?: string }).license = license;1376 }1377 return { ok: true, data: identity };1378 } finally {1379 masterKey.fill(0);1380 }1381 }13821383 case "GET_ADOPTION_MATERIAL": {1384 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1385 const rootKeyHex = vault.getRootKeyHex();1386 if (!rootKeyHex) return { ok: false, error: "vault_locked" };1387 const rootKeyBytes = unhexBytes(rootKeyHex);1388 try {1389 const data: { rootKeyHex: string; vaultId: string; license?: string } = {1390 rootKeyHex,1391 vaultId: await computeVaultId(rootKeyBytes),1392 };1393 if (await isProUser()) {1394 const license = await getLicenseKey();1395 if (license) data.license = license;1396 }1397 return { ok: true, data };1398 } finally {1399 rootKeyBytes.fill(0);1400 }1401 }14021403 case "COMPLETE_PAIRING": {1404 1405 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1406 const paired = (msg.payload as { paired?: unknown })?.paired;1407 if (typeof paired !== "object" || paired === null) {1408 return { ok: false, error: "invalid_payload" };1409 }1410 const candidate = paired as Record<string, unknown>;1411 if (1412 typeof candidate.deviceId !== "string" || !candidate.deviceId ||1413 typeof candidate.deviceName !== "string" || !candidate.deviceName ||1414 typeof candidate.deviceType !== "string" ||1415 typeof candidate.longTermPublicKeyHex !== "string" ||1416 !/^[0-9a-f]{64}$/.test(candidate.longTermPublicKeyHex) ||1417 (1418 candidate.signingPublicKeyHex !== undefined &&1419 (1420 typeof candidate.signingPublicKeyHex !== "string" ||1421 !/^[0-9a-f]{64}$/.test(candidate.signingPublicKeyHex)1422 )1423 ) ||1424 typeof candidate.pairedAt !== "number"1425 ) {1426 return { ok: false, error: "invalid_paired_device" };1427 }1428 1429 1430 1431 1432 await clearDeviceRevocation(candidate.deviceId);1433 await upsertPairedDeviceV3(candidate as unknown as import("../shared/sync-types").PairedDevice);1434 1435 1436 1437 void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);1438 return { ok: true };1439 }14401441 case "LIST_PAIRED_DEVICES": {1442 const devices = await listPairedDevicesV3();1443 return { ok: true, data: { devices } };1444 }14451446 case "UNPAIR_DEVICE": {1447 const { deviceId } = (msg.payload ?? {}) as { deviceId?: string };1448 if (typeof deviceId !== "string" || !deviceId) {1449 return { ok: false, error: "invalid_payload" };1450 }1451 1452 1453 1454 await revokeDeviceByIdV3(deviceId, await getOrCreateDeviceId());1455 1456 1457 void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);1458 return { ok: true };1459 }14601461 case "RENAME_LOCAL_DEVICE": {1462 const { name } = (msg.payload ?? {}) as { name?: string };1463 if (typeof name !== "string") return { ok: false, error: "invalid_payload" };1464 await setDeviceNameV3(name);1465 return { ok: true };1466 }14671468 1469 1470 1471 1472 case "START_ADOPTION_SOURCE": {1473 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1474 if (isRelayAdoptionSourceInFlight()) {1475 return { ok: false, error: "adoption_source_in_progress" };1476 }1477 try {1478 return { ok: true, data: await startRelayAdoptionSource() };1479 } catch (error) {1480 return {1481 ok: false,1482 error: error instanceof Error ? error.message : "adoption_source_failed",1483 };1484 }1485 }14861487 1488 1489 1490 1491 case "START_ADOPTION_RECEIVER": {1492 if (isRelayAdoptionSourceInFlight()) {1493 return { ok: false, error: "adoption_receiver_in_progress" };1494 }1495 const password = (msg.payload as { password?: unknown } | undefined)?.password;1496 if (typeof password !== "string" || password.length === 0) {1497 return { ok: false, error: "invalid_payload" };1498 }1499 try {1500 return { ok: true, data: await startRelayAdoptionReceiver(password) };1501 } catch (error) {1502 return {1503 ok: false,1504 error: error instanceof Error ? error.message : "adoption_receiver_failed",1505 };1506 }1507 }15081509 1510 1511 1512 1513 1514 1515 15161517 1518 1519 1520 case "SOURCE_SAS_DECISION": {1521 const payload = msg.payload as { id?: unknown; ok?: unknown } | undefined;1522 if (typeof payload?.id !== "string" || typeof payload?.ok !== "boolean") {1523 return { ok: false, error: "invalid_payload" };1524 }1525 return { ok: resolveSourceSasConfirmation(payload.id, payload.ok) };1526 }15271528 case "CANCEL_ADOPTION_SOURCE": {1529 cancelRelayAdoptionSource();1530 return { ok: true };1531 }15321533 case "TRIGGER_SYNC": {1534 const payload = (msg.payload ?? {}) as {1535 reason?: "vault_change" | "foreground" | "manual";1536 force?: boolean;1537 };1538 const reason = payload.reason ?? "foreground";15391540 void triggerSyncIfReachable({ reason, force: payload.force === true });1541 return { ok: true };1542 }15431544 case "GET_SYNC_STATE": {1545 return { ok: true, data: getSyncState() };1546 }15471548 case "GET_SYNC_SETTINGS": {1549 return { ok: true, data: await getSyncSettings() };1550 }15511552 case "SET_SYNC_SETTINGS": {1553 const payload = (msg.payload ?? {}) as {1554 mode?: unknown;1555 relayUrl?: unknown;1556 };1557 try {1558 1559 1560 if (payload.relayUrl !== undefined) {1561 if (typeof payload.relayUrl !== "string") {1562 return { ok: false, error: "invalid_relay_url" };1563 }1564 return { ok: true, data: await setRelayUrl(payload.relayUrl) };1565 }1566 if (payload.mode !== undefined) {1567 if (payload.mode !== "public" && payload.mode !== "private" && payload.mode !== "off") {1568 return { ok: false, error: "invalid_sync_mode" };1569 }1570 return { ok: true, data: await setSyncMode(payload.mode as SyncMode) };1571 }1572 return { ok: true, data: await getSyncSettings() };1573 } catch (error) {1574 return {1575 ok: false,1576 error: error instanceof Error ? error.message : "invalid_relay_url",1577 };1578 }1579 }15801581 case "GET_SYNC_ALERTS": {1582 const { consume } = (msg.payload ?? {}) as { consume?: boolean };1583 const alerts = await getPendingSyncAlerts();1584 1585 1586 1587 if (consume) await clearPendingSyncAlerts();1588 return { ok: true, data: alerts };1589 }15901591 case "GET_PENDING_VOUCHED_DEVICES": {1592 return { ok: true, data: await listPendingVouchedDevices() };1593 }1594 case "CONFIRM_VOUCHED_DEVICE": {1595 const { deviceId } = msg.payload as { deviceId: string };1596 const pending = await getPendingVouchedDevice(deviceId);1597 if (!pending) return { ok: false, error: "not_pending" };1598 1599 1600 1601 if (await isDeviceRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {1602 await removePendingVouchedDevice(deviceId);1603 return { ok: false, error: "device_revoked" };1604 }1605 await upsertPairedDeviceV3({1606 deviceId: pending.deviceId,1607 deviceName: pending.deviceName,1608 deviceType:1609 pending.deviceType as import("../shared/sync-types").PairedDevice["deviceType"],1610 longTermPublicKeyHex: pending.longTermPublicKeyHex,1611 signingPublicKeyHex: pending.signingPublicKeyHex,1612 wsPort: pending.wsPort,1613 pairedAt: pending.pairedAt,1614 trustSource: "vouched",1615 });1616 await removePendingVouchedDevice(deviceId);1617 1618 1619 void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);1620 return { ok: true };1621 }1622 case "REJECT_VOUCHED_DEVICE": {1623 const { deviceId } = msg.payload as { deviceId: string };1624 await removePendingVouchedDevice(deviceId);1625 return { ok: true };1626 }1627 case "ROTATE_SYNC_IDENTITY": {1628 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1629 const result = await vault.rotateSyncIdentity();1630 return result.ok ? { ok: true } : { ok: false, error: result.error };1631 }16321633 default:1634 return { ok: false, error: "unknown_message_type" };1635 }1636 } catch (err) {1637 return {1638 ok: false,1639 error: err instanceof Error ? err.message : "operation_failed",1640 };1641 }1642}16431644164516461647async function publishInMemoryVault(entries: VaultEntry[]): Promise<void> {1648 const plugin = nativePlugin<{1649 setEntries?: (args: {1650 entries: Array<{1651 id: string;1652 title: string;1653 username: string;1654 email: string;1655 password: string;1656 url: string;1657 totp: string;1658 additionalUrls: string[];1659 }>;1660 }) => Promise<void>;1661 }>("InMemoryVaultPlugin");1662 if (!plugin?.setEntries) return;1663 await plugin.setEntries({1664 entries: entries.map((e) => ({1665 id: e.id,1666 title: e.title ?? "",1667 username: e.username ?? "",1668 email: (e as VaultEntry & { email?: string }).email ?? "",1669 password: e.password ?? "",1670 url: e.url ?? "",1671 totp: (e as VaultEntry & { totp?: string }).totp ?? "",1672 additionalUrls: (e as VaultEntry & { additionalUrls?: string[] }).additionalUrls ?? [],1673 })),1674 });1675}167616771678async function clearInMemoryVault(): Promise<void> {1679 const plugin = nativePlugin<{ clearEntries?: () => Promise<void> }>("InMemoryVaultPlugin");1680 if (!plugin?.clearEntries) return;1681 await plugin.clearEntries();1682}16831684168516861687function buildIdentitySuggestions(1688 entries: VaultEntry[],1689): Array<{ email: string; username: string; count: number }> {1690 const usernameCounts = new Map<string, number>();1691 const usernameEmail = new Map<string, string>();1692 for (const entry of entries) {1693 const username = entry.username ?? "";1694 const email = (entry as VaultEntry & { email?: string }).email ?? "";1695 if (!username && !email) continue;1696 const key = username || email;1697 usernameCounts.set(key, (usernameCounts.get(key) ?? 0) + 1);1698 if (!usernameEmail.has(key)) usernameEmail.set(key, email);1699 }1700 return Array.from(usernameCounts.entries())1701 .sort((a, b) => b[1] - a[1])1702 .map(([username, count]) => ({1703 email: usernameEmail.get(username) ?? "",1704 username,1705 count,1706 }));1707}170817091710function broadcastToContentScripts(message: { type: string }): void {1711 if (!chrome.tabs?.query) return;1712 chrome.tabs.query({}, (tabs) => {1713 for (const tab of tabs) {1714 if (tab.id !== undefined) {1715 chrome.tabs.sendMessage(tab.id, message).catch(() => {1716 1717 });1718 }1719 }1720 });1721}17221723function shouldRefreshAutoLock(type: MessageType): boolean {1724 return (1725 type !== "HAS_VAULT" &&1726 type !== "IS_UNLOCKED" &&1727 type !== "GET_VAULT_STATUS" &&1728 type !== "LOCK" &&1729 type !== "UPDATE_SETTINGS" &&1730 type !== "GET_PENDING_PASSKEY" &&1731 type !== "GET_PASSKEY_PROVIDER_STATE" &&1732 type !== "DECLINE_CARD_SAVE" &&1733 type !== "IS_CARD_DECLINED"1734 );1735}17361737function isAllowedCanaryWebhookUrl(value: string): boolean {1738 try {1739 const url = new URL(value);1740 if (url.protocol !== "https:") return false;17411742 const hostname = url.hostname.toLowerCase();1743 if (1744 hostname === "localhost" ||1745 hostname.endsWith(".localhost") ||1746 isPrivateIpv4(hostname) ||1747 isPrivateIpv6(hostname)1748 ) {1749 return false;1750 }17511752 return true;1753 } catch {1754 return false;1755 }1756}17571758function isPrivateIpv4(hostname: string): boolean {1759 if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {1760 return true;1761 }17621763 const octets = hostname.split(".").map((part) => Number(part));1764 if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {1765 return false;1766 }17671768 return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;1769}17701771function isPrivateIpv6(hostname: string): boolean {1772 const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();1773 return (1774 normalized === "::1" ||1775 normalized.startsWith("fc") ||1776 normalized.startsWith("fd") ||1777 normalized.startsWith("fe80:")1778 );1779}