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