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