123456789101112import { copyToClipboard } from "./clipboard";13import { setupAutoLock, touchAutoLock } from "./alarms";14import * as vault from "./vault";15import {16 activateLicense,17 deactivateLicense,18 getLicenseInfo,19 getLicenseKey,20 isProUser,21 markCelebrationPlayed,22 shouldPlayCelebration,23} from "./license";24import {25 cancelFirefoxPasskeyCreate,26 completeFirefoxPasskeyCreate,27 getPasskeyProviderState,28 getPendingPasskeyRequest,29 handleFirefoxPasskeyCreate,30 handleFirefoxPasskeyGet,31 handlePasskeyHasCredentials,32 initiateFirefoxPasskeyCreate,33 isPasskeyOfferDeclined,34 recordPasskeyOfferDecline,35 resolvePendingPasskeyRequest,36 setPasskeyProviderEnabled,37} from "./passkey";38import { buildAdvancedHealthReport, resolveResetUrl } from "./health";39import { Capacitor } from "@capacitor/core";40import { storageClear } from "../shared/storage";41import type {42 Message,43 MessageResponse,44 MessageType,45 ProFeature,46} from "../shared/types";47import { isPasskeyOnly } from "../shared/types";48import type { SyncStatus } from "../shared/sync-types";4950515253545556async function requirePro(57 feature: ProFeature,58 trigger: "user" | "background" = "user"59): Promise<{ allowed: boolean }> {60 if (await isProUser()) return { allowed: true };6162 if (trigger === "user") {63 64 if (!Capacitor.isNativePlatform()) {65 chrome.runtime66 .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })67 .catch(() => {68 69 });70 }71 } else {72 73 74 75 if (!Capacitor.isNativePlatform() && chrome.storage.session) {76 chrome.storage.session77 .set({ pendingProNotice: { feature, triggeredAt: Date.now() } })78 .catch(() => {});79 }80 }8182 return { allowed: false };83}848586878889909192939495let peerDiscoverySubscriptions: Array<() => void> = [];9697async function startPeerDiscoveryForPopup(): Promise<void> {98 await stopPeerDiscoveryForPopup();99 const sync = await import('./sync');100 const { getPairedDevices } = await import('./sync-storage');101 sync.connectSyncHost();102103 const unsubDiscovered = sync.onPeerDiscovered((peer) => {104 void (async () => {105 const paired = await getPairedDevices();106 const alreadyPaired = paired.some((d) => d.deviceId === peer.deviceId);107 if (Capacitor.isNativePlatform()) return;108 chrome.runtime109 .sendMessage({110 type: "SYNC_PEER_DISCOVERED",111 peer: {112 deviceId: peer.deviceId,113 deviceName: peer.name,114 host: peer.host,115 port: peer.port,116 wsPort: peer.wsPort,117 x25519PublicKey: peer.x25519PublicKey,118 alreadyPaired,119 },120 })121 .catch(() => {});122 })();123 });124 const unsubLost = sync.onPeerLost((deviceId) => {125 if (Capacitor.isNativePlatform()) return;126 chrome.runtime127 .sendMessage({ type: "SYNC_PEER_LOST", deviceId })128 .catch(() => {});129 });130 peerDiscoverySubscriptions = [unsubDiscovered, unsubLost];131 sync.discoverPeers();132}133134async function stopPeerDiscoveryForPopup(): Promise<void> {135 for (const unsub of peerDiscoverySubscriptions) unsub();136 peerDiscoverySubscriptions = [];137 try {138 const sync = await import('./sync');139 if (sync.isHostConnected()) {140 await sync.stopDiscovery();141 }142 } catch {143 144 }145}146147const PRIVATE_IPV4_RANGES = [148 { prefix: "10." },149 { prefix: "127." },150 { prefix: "169.254." },151 { prefix: "192.168." },152];153154155156157158const FEEDBACK_ENDPOINT = "";159160export function setupMessageHandler(): void {161 chrome.runtime.onMessage.addListener(162 (msg: unknown, sender, sendResponse) => {163 if (!isMessage(msg)) {164 sendResponse({ ok: false, error: "invalid message" });165 return true;166 }167 handleMessage(msg, sender)168 .then(sendResponse)169 .catch((err) =>170 sendResponse({171 ok: false,172 error: err instanceof Error ? err.message : String(err),173 })174 );175 return true;176 }177 );178}179180function isMessage(m: unknown): m is Message {181 return typeof m === "object" && m !== null && "type" in m;182}183184export async function handleMessage(185 msg: Message,186 sender: Partial<chrome.runtime.MessageSender> = {}187): Promise<MessageResponse> {188 try {189 190 191 192 193 if (194 !Capacitor.isNativePlatform() &&195 sender.id !== undefined &&196 sender.id !== chrome.runtime.id197 ) {198 return { ok: false, error: "invalid sender" };199 }200201 if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {202 touchAutoLock();203 }204205 switch (msg.type) {206 case "CREATE_VAULT": {207 const { password } = msg.payload as { password: string };208 await vault.createVault(password);209 await setupAutoLock();210 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });211 return { ok: true };212 }213 case "UNLOCK": {214 const { password } = msg.payload as { password: string };215 const ok = await vault.unlock(password);216 if (ok) {217 await setupAutoLock();218 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });219 }220 return { ok };221 }222 case "LOCK":223 vault.lock();224 return { ok: true };225 case "IS_UNLOCKED":226 return { ok: true, data: vault.isUnlocked() };227 case "GET_VAULT_STATUS":228 return { ok: true, unlocked: vault.isUnlocked() };229 case "HAS_VAULT":230 return { ok: true, data: await vault.hasVault() };231 case "GET_ENTRIES":232 return { ok: true, data: vault.getEntries() };233 case "ADD_ENTRY": {234 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };235 const entry = await vault.addEntry(msg.payload as any);236 return { ok: true, data: entry };237 }238 case "BATCH_ADD_ENTRIES": {239 const { entries } = msg.payload as { entries: any[] };240 const added = await vault.batchAddEntries(entries);241 return { ok: true, data: added };242 }243 case "RESTORE_ENTRIES": {244 const { entries, mode, settings } = msg.payload as {245 entries: any[];246 mode: "replace" | "merge";247 settings?: vault.VaultSettings;248 };249 const summary = await vault.restoreEntries(entries, mode);250 if (settings && typeof settings === "object") {251 await vault.updateSettings(settings);252 }253 return { ok: true, data: summary };254 }255 case "CLEAR_VAULT_ENTRIES": {256 await vault.clearVaultEntries("REPLACE_ALL");257 return { ok: true };258 }259 case "GET_VAULT_HISTORY": {260 const snapshots = await vault.getVaultHistory();261 return { ok: true, data: snapshots };262 }263 case "RESTORE_VAULT_SNAPSHOT": {264 const { snapshotId } = msg.payload as { snapshotId: string };265 const result = await vault.restoreVaultSnapshot(snapshotId);266 if (!result.ok) return { ok: false, error: result.error };267 broadcastToContentScripts({ type: "VAULT_LOCKED" });268 return { ok: true };269 }270 case "COUNT_DUPLICATES": {271 return { ok: true, data: vault.countDuplicates() };272 }273 case "DEDUPLICATE_ENTRIES": {274 const removed = await vault.deduplicateEntries();275 return { ok: true, data: removed };276 }277 case "UPDATE_ENTRY": {278 const { id, patch } = msg.payload as any;279 await vault.updateEntry(id, patch);280 return { ok: true };281 }282 case "ADD_ADDITIONAL_URL": {283 const { id, url } = msg.payload as { id: string; url: string };284 const trimmed = typeof url === "string" ? url.trim() : "";285 if (!trimmed) return { ok: false, error: "url required" };286 const existing = vault.getEntryById(id);287 if (!existing) return { ok: false, error: "entry not found" };288 if (existing.type !== "login") return { ok: false, error: "login entries only" };289 const merged = [...(existing.additionalUrls ?? []), trimmed];290 await vault.updateEntry(id, { additionalUrls: merged });291 return { ok: true };292 }293 case "DELETE_ENTRY": {294 const { id } = msg.payload as { id: string };295 await vault.deleteEntry(id);296 return { ok: true };297 }298 case "GET_DELETED_ENTRIES":299 return { ok: true, data: vault.getDeletedEntries() };300 case "RESTORE_ENTRY": {301 const { id } = msg.payload as { id: string };302 await vault.restoreEntry(id);303 return { ok: true };304 }305 case "PURGE_ENTRY": {306 const { id } = msg.payload as { id: string };307 await vault.purgeEntry(id);308 return { ok: true };309 }310 case "PURGE_EXPIRED_TRASH": {311 const { retentionMs } = msg.payload as { retentionMs: number };312 const purged = await vault.purgeExpiredTrash(retentionMs);313 return { ok: true, data: purged };314 }315 case "AUTOFILL_QUERY": {316 if (!vault.isUnlocked()) {317 return { ok: false, error: "VAULT_LOCKED" };318 }319 const { domain, includeAll, kind } = msg.payload as {320 domain: string;321 includeAll?: boolean;322 kind?: "login" | "card";323 };324 325 326 327 328 if (kind === "card") {329 const cards = vault.getEntries().filter((e) => e.type === "card");330 return {331 ok: true,332 data: cards.map((e) => ({333 id: e.id,334 title: e.cardNickname || e.title,335 cardholderName: e.cardholderName,336 last4: e.last4,337 expMonth: e.expMonth,338 expYear: e.expYear,339 network: e.network,340 })),341 };342 }343 344 345 346 347 348 const matches = (349 includeAll350 ? vault351 .getEntries()352 .filter((entry) => entry.type !== "note" && entry.type !== "card")353 : vault.queryDomain(domain)354 ).filter((entry) => !isPasskeyOnly(entry));355 return {356 ok: true,357 data: matches.map((e) =>358 includeAll359 ? {360 id: e.id,361 title: e.title,362 hasPasskey: e.passkey !== undefined,363 }364 : {365 id: e.id,366 title: e.title,367 username: e.username,368 url: e.url,369 hasTotp: Boolean(e.totp),370 hasPasskey: e.passkey !== undefined,371 }372 ),373 };374 }375 case "GET_ENTRY_FOR_FILL": {376 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };377 const { id } = msg.payload as { id: string };378 const entry = vault.getEntryById(id);379 if (!entry) return { ok: false, error: "entry not found" };380 381 382 383 if (isPasskeyOnly(entry)) return { ok: false, error: "passkey not fillable" };384 385 386 387 const { passkey: _strippedPasskey, ...safeEntry } = entry;388 return { ok: true, data: safeEntry };389 }390 case "COPY": {391 const { text, label, alreadyWritten } = msg.payload as {392 text: string;393 label: string;394 alreadyWritten?: boolean;395 };396 await copyToClipboard(text, label, Boolean(alreadyWritten));397 return { ok: true };398 }399 case "CHECK_BREACH": {400 const { password } = msg.payload as { password: string };401 const count = await vault.checkBreach(password);402 return { ok: true, data: count };403 }404 case "CHECK_ALL_BREACHES": {405 const allEntries = vault.getEntries();406 const results: Record<string, number> = {};407 for (const entry of allEntries) {408 if (entry.type === "note") continue;409 if (!entry.password) continue;410 results[entry.id] = await vault.checkBreach(entry.password);411 412 await new Promise((r) => setTimeout(r, 200));413 }414 415 for (const [id, count] of Object.entries(results)) {416 const entry = vault.getEntryById(id);417 if (entry && (entry.breach ?? 0) !== count) {418 await vault.updateEntry(id, { breach: count > 0 ? count : undefined });419 }420 }421 return { ok: true, data: results };422 }423 case "GET_SETTINGS":424 return { ok: true, data: await vault.ensureSettingsLoaded() };425 case "UPDATE_SETTINGS": {426 const patch = msg.payload as Record<string, unknown>;427 await vault.updateSettings(patch as any);428 if (typeof patch.autoLockMinutes === "number") {429 await setupAutoLock();430 if (vault.isUnlocked()) touchAutoLock();431 }432 return { ok: true };433 }434 case "GET_PENDING_PASSKEY":435 return { ok: true, data: await getPendingPasskeyRequest() };436 case "APPROVE_PASSKEY": {437 const { requestId } = msg.payload as { requestId: number };438 await resolvePendingPasskeyRequest(requestId, true);439 return { ok: true };440 }441 case "DENY_PASSKEY": {442 const { requestId } = msg.payload as { requestId: number };443 await resolvePendingPasskeyRequest(requestId, false);444 return { ok: true };445 }446 case "GET_PASSKEYS":447 return {448 ok: true,449 data: vault.getEntries().filter((entry) => entry.passkey !== undefined),450 };451 case "DELETE_PASSKEY": {452 const { id } = msg.payload as { id: string };453 await vault.deleteEntry(id);454 return { ok: true };455 }456 case "GET_PASSKEY_PROVIDER_STATE":457 return { ok: true, data: await getPasskeyProviderState() };458 case "SET_PASSKEY_PROVIDER_ENABLED": {459 const { enabled } = msg.payload as { enabled: boolean };460 461 462 if (enabled) {463 const gate = await requirePro("passkeys");464 if (!gate.allowed) return { ok: false, error: "pro_required" };465 }466 return { ok: true, data: await setPasskeyProviderEnabled(enabled) };467 }468 case "FIRE_CANARY_PING": {469 const { webhookUrl, entryTitle, trigger } = msg.payload as {470 webhookUrl: string;471 entryTitle: string;472 trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";473 };474 if (!isAllowedCanaryWebhookUrl(webhookUrl)) {475 return { ok: false, error: "invalid webhook url" };476 }477 const body = JSON.stringify({478 event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",479 entry: entryTitle,480 trigger,481 timestamp: new Date().toISOString(),482 source: "peach-extension",483 });484 if (trigger === "test") {485 486 487 488 489 490 try {491 const res = await fetch(webhookUrl, {492 method: "POST",493 headers: { "Content-Type": "application/json" },494 body,495 });496 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };497 } catch (err) {498 return { ok: false, error: err instanceof Error ? err.message : "network error" };499 }500 }501 502 fetch(webhookUrl, {503 method: "POST",504 headers: { "Content-Type": "application/json" },505 body,506 }).catch(() => {});507 return { ok: true };508 }509 case "ACTIVATE_LICENSE": {510 const { key } = msg.payload as { key: string };511 const licenseInfo = await activateLicense(key);512 return { ok: true, data: licenseInfo };513 }514 case "GET_LICENSE": {515 const licenseInfo = await getLicenseInfo();516 return { ok: true, data: licenseInfo };517 }518 case "GET_LICENSE_KEY": {519 const key = await getLicenseKey();520 return { ok: true, data: key };521 }522 case "DEACTIVATE_LICENSE": {523 await deactivateLicense();524 return { ok: true };525 }526 case "SHOULD_PLAY_CELEBRATION": {527 const should = await shouldPlayCelebration();528 return { ok: true, data: should };529 }530 case "MARK_CELEBRATION_PLAYED": {531 await markCelebrationPlayed();532 return { ok: true };533 }534 case "OPEN_PRO_GATE": {535 536 const { feature } = (msg.payload ?? {}) as { feature?: string };537 if (chrome.storage.session) {538 await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });539 }540 try { await chrome.action.openPopup(); } catch { }541 return { ok: true };542 }543 case "GET_IDENTITIES": {544 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };545 return { ok: true, data: vault.getIdentities() };546 }547 case "GET_ADVANCED_HEALTH_REPORT": {548 const gate = await requirePro("advanced_health");549 if (!gate.allowed) return { ok: false, error: "pro_required" };550 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };551 const report = await buildAdvancedHealthReport(vault.getEntries());552 return { ok: true, data: report };553 }554 case "INITIATE_PASSWORD_CHANGE": {555 const gate = await requirePro("smart_password_change");556 if (!gate.allowed) return { ok: false, error: "pro_required" };557 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };558 const { id } = msg.payload as { id: string };559 const entry = vault.getEntryById(id);560 if (!entry) return { ok: false, error: "entry not found" };561 const url = resolveResetUrl(entry);562 if (!url) return { ok: false, error: "no reset url" };563 try {564 await chrome.tabs.create({ url });565 return { ok: true, data: { url } };566 } catch (err) {567 return {568 ok: false,569 error: err instanceof Error ? err.message : "failed to open tab",570 };571 }572 }573 case "IS_DURESS_ENABLED": {574 const enabled = await vault.isDuressEnabled();575 return { ok: true, data: enabled };576 }577 case "SETUP_DURESS": {578 const { duressPassword } = msg.payload as { duressPassword: string };579 await vault.setupDuress(duressPassword);580 return { ok: true };581 }582 case "DISABLE_DURESS": {583 await vault.disableDuress();584 return { ok: true };585 }586 case "VERIFY_MAIN_PASSWORD": {587 const { password } = msg.payload as { password: string };588 const ok = await vault.verifyMainPassword(password);589 return { ok: true, data: ok };590 }591 case "CHANGE_PASSWORD": {592 const { currentPassword, newPassword } = msg.payload as {593 currentPassword: string;594 newPassword: string;595 };596 const changed = await vault.changePassword(currentPassword, newPassword);597 return changed598 ? { ok: true }599 : { ok: false, error: "incorrect current password" };600 }601 case "GET_SITE_PASSWORD_POLICY": {602 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });603 if (!tab?.id) return { ok: true, data: null };604 try {605 const response = await chrome.tabs.sendMessage(tab.id, {606 type: "GET_SITE_PASSWORD_POLICY",607 });608 return (response as MessageResponse) ?? { ok: true, data: null };609 } catch {610 return { ok: true, data: null };611 }612 }613 case "GET_SYNC_STATE": {614 const { getPairedDevices, getTrustedNetworks } = await import('./sync-storage');615 const { isOnTailscale } = await import('./sync-network');616 const { isProUser } = await import('./license');617 const pairedDevices = await getPairedDevices();618 const trustedNetworks = await getTrustedNetworks();619 const tailscale = await isOnTailscale();620 const pro = await isProUser();621 622 let status: SyncStatus = "offline";623 if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {624 if (pairedDevices.length === 0) {625 status = "no_peers";626 } else {627 status = "synced";628 }629 } else {630 status = "not_trusted";631 }632 633 return { ok: true, data: { pairedDevices, trustedNetworks, status, isPro: pro } };634 }635 case "TRIGGER_SYNC": {636 const gate = await requirePro("sync");637 if (!gate.allowed) return { ok: false, error: "pro_required" };638 return { ok: true };639 }640 case "START_PAIRING": {641 const gate = await requirePro("sync");642 if (!gate.allowed) return { ok: false, error: "pro_required" };643 return { ok: true };644 }645 case "CANCEL_PAIRING": {646 return { ok: true };647 }648 case "ACCEPT_PAIRING": {649 const gate = await requirePro("sync");650 if (!gate.allowed) return { ok: false, error: "pro_required" };651 return { ok: true };652 }653 case "REMOVE_PAIRED_DEVICE": {654 const { deviceId } = msg.payload as { deviceId: string };655 const { removePairedDevice } = await import('./sync-storage');656 await removePairedDevice(deviceId);657 return { ok: true };658 }659 case "REMOVE_TRUSTED_NETWORK": {660 const { ssid, bssid, isTailscale } = msg.payload as any;661 const { removeTrustedNetwork, getTrustedNetworks } = await import('./sync-storage');662 const networks = await getTrustedNetworks();663 const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 664 (isTailscale ? n.isTailscale : n.ssid === ssid) && 665 (!bssid || n.bssid === bssid)666 );667 if (network) {668 await removeTrustedNetwork(network.addedAt);669 }670 return { ok: true };671 }672 case "MARK_NETWORK_TRUSTED": {673 const { addTrustedNetwork } = await import('./sync-storage');674 const { getCurrentNetwork } = await import('./sync-network');675 const network = await getCurrentNetwork();676 await addTrustedNetwork({677 ssid: network.ssid,678 bssid: network.bssid,679 isTailscale: network.isTailscale,680 addedAt: Date.now(),681 });682 return { ok: true };683 }684 case "GET_INCOMING_PAIRING_REQUEST": {685 const { getIncomingPairingRequest } = await import('./sync-storage');686 const request = await getIncomingPairingRequest();687 return { ok: true, data: request };688 }689 case "RESPOND_INCOMING_PAIRING": {690 const { accept } = msg.payload as { accept: boolean };691 const { getIncomingPairingRequest, clearIncomingPairingRequest } = await import('./sync-storage');692 const request = await getIncomingPairingRequest();693 if (!request) return { ok: false, error: "no_pending_request" };694 await clearIncomingPairingRequest();695 if (accept) {696 697 698 699 700 try {701 const { trustDevice } = await import('./sync');702 await trustDevice(request.publicKeyHex, request.deviceName);703 } catch {704 705 }706 }707 return { ok: true, data: { accepted: accept, deviceName: request.deviceName } };708 }709 case "START_PEER_DISCOVERY": {710 const gate = await requirePro("sync");711 if (!gate.allowed) return { ok: false, error: "pro_required" };712 try {713 await startPeerDiscoveryForPopup();714 return { ok: true };715 } catch (err) {716 return { ok: false, error: err instanceof Error ? err.message : "discovery_failed" };717 }718 }719 case "STOP_PEER_DISCOVERY": {720 try {721 await stopPeerDiscoveryForPopup();722 } catch {723 724 }725 return { ok: true };726 }727 case "INITIATE_PAIR_WITH_PEER": {728 const gate = await requirePro("sync");729 if (!gate.allowed) return { ok: false, error: "pro_required" };730 const { peer } = msg.payload as {731 peer: { deviceId: string; deviceName: string; host: string; port: number; x25519PublicKey: string };732 };733 try {734 const sync = await import('./sync');735 const connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);736 737 738 739 740 const { addPairedDevice } = await import('./sync-storage');741 await addPairedDevice({742 deviceId: peer.deviceId,743 displayName: peer.deviceName,744 x25519PublicKey: peer.x25519PublicKey,745 pairedAt: Date.now(),746 });747 return { ok: true, data: { connectionId, deviceName: peer.deviceName } };748 } catch (err) {749 return { ok: false, error: err instanceof Error ? err.message : "pair_failed" };750 }751 }752 case "DEBUG_INJECT_INCOMING_PAIRING": {753 754 755 756 757 const { deviceName, publicKeyHex } = (msg.payload ?? {}) as {758 deviceName?: string;759 publicKeyHex?: string;760 };761 if (!deviceName || !publicKeyHex) {762 return { ok: false, error: "missing_payload" };763 }764 const { setIncomingPairingRequest } = await import('./sync-storage');765 const now = Date.now();766 await setIncomingPairingRequest({767 deviceName,768 publicKeyHex,769 receivedAt: now,770 expiresAt: now + 60_000,771 });772 return { ok: true };773 }774 case "RESET_SYNC_IDENTITY": {775 const gate = await requirePro("sync");776 if (!gate.allowed) return { ok: false, error: "pro_required" };777 const { getPairedDevices, removePairedDevice } = await import('./sync-storage');778 const devices = await getPairedDevices();779 for (const device of devices) {780 await removePairedDevice(device.deviceId);781 }782 783 784 785 786 let nativePublicKey: string | null = null;787 try {788 const { rotateNativeIdentity, disconnectSyncHost } = await import('./sync');789 nativePublicKey = await rotateNativeIdentity();790 disconnectSyncHost();791 } catch {792 nativePublicKey = null;793 }794 return { ok: true, data: { removedDevices: devices.length, nativePublicKey } };795 }796 case "RECORD_MANUAL_SUMMON": {797 const { domain } = msg.payload as { domain: string };798 const settings = vault.getSettings();799 const existing = settings.manualSummonDomains?.[domain];800 const now = Date.now();801 await vault.updateSettings({802 manualSummonDomains: {803 ...settings.manualSummonDomains,804 [domain]: {805 firstSummonedAt: existing?.firstSummonedAt ?? now,806 summonCount: (existing?.summonCount ?? 0) + 1,807 lastSummonedAt: now,808 },809 },810 });811 return { ok: true };812 }813 case "DECLINE_CARD_SAVE": {814 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };815 await vault.declineCardSaveOffer(last4, expMonth, expYear);816 return { ok: true };817 }818 case "IS_CARD_DECLINED": {819 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };820 const declined = await vault.isCardDeclined(last4, expMonth, expYear);821 return { ok: true, data: declined };822 }823 case "INITIATE_FIREFOX_PASSKEY_CREATE": {824 const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {825 rpId?: string;826 rpName?: string;827 userName?: string;828 userDisplayName?: string;829 };830 if (!rpId || !userName) return { ok: false, error: "rpId and userName required" };831 return await initiateFirefoxPasskeyCreate({832 rpId,833 rpName: rpName ?? rpId,834 userName,835 userDisplayName,836 });837 }838839 case "COMPLETE_FIREFOX_PASSKEY_CREATE": {840 const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;841 if (!payload) return { ok: false, error: "missing payload" };842 return await completeFirefoxPasskeyCreate(payload);843 }844845 case "PASSKEY_OFFER_DECLINE": {846 const { domain } = (msg.payload ?? {}) as { domain?: string };847 if (!domain) return { ok: false, error: "domain required" };848 await recordPasskeyOfferDecline(domain);849 return { ok: true };850 }851852 case "GET_PASSKEY_OFFER_DECLINED": {853 const { domain } = (msg.payload ?? {}) as { domain?: string };854 if (!domain) return { ok: false, error: "domain required" };855 return { ok: true, data: await isPasskeyOfferDeclined(domain) };856 }857858 case "PASSKEY_HAS_CREDENTIALS": {859 const payload = msg.payload as {860 rpId?: string;861 allowCredentials?: Array<{ id: string; type?: string }>;862 } | undefined;863 if (!payload?.rpId) return { ok: false, error: "rpId required" };864 return await handlePasskeyHasCredentials({865 rpId: payload.rpId,866 allowCredentials: payload.allowCredentials ?? [],867 });868 }869870 case "PASSKEY_CREATE": {871 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;872 if (!payload?.rpId || !payload?.userName) {873 return { ok: false, error: "rpId and userName required" };874 }875 return await handleFirefoxPasskeyCreate(payload);876 }877878 case "PASSKEY_GET": {879 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;880 if (!payload?.rpId) return { ok: false, error: "rpId required" };881 return await handleFirefoxPasskeyGet(payload);882 }883884 case "GET_PASSKEY_FOR_DOMAIN": {885 const { domain } = (msg.payload ?? {}) as { domain?: string };886 if (!domain) return { ok: false, error: "domain required" };887 if (!vault.isUnlocked()) return { ok: true, data: null };888 const match = vault889 .getEntries()890 .find((entry) => entry.passkey?.rpId === domain);891 if (!match || !match.passkey) return { ok: true, data: null };892 893 894 return {895 ok: true,896 data: {897 rpId: match.passkey.rpId,898 userName: match.passkey.userName,899 browser: match.passkey.browser,900 },901 };902 }903904 case "DESTROY_VAULT": {905 906 907 908 909 910 vault.lock();911 await storageClear();912 913 914 915 916 917 await vault.clearVaultHistory();918 if (!Capacitor.isNativePlatform() && chrome.storage.session) {919 await chrome.storage.session.clear();920 }921 return { ok: true };922 }923924 case "SUBMIT_FEEDBACK": {925 const { category, message, version, browser } = msg.payload as {926 category: string;927 message: string;928 version: string;929 browser: string;930 };931 if (!message.trim()) return { ok: false, error: "message is empty" };932 if (!FEEDBACK_ENDPOINT) return { ok: true };933 if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {934 return { ok: false, error: "invalid feedback endpoint" };935 }936 try {937 const res = await fetch(FEEDBACK_ENDPOINT, {938 method: "POST",939 headers: { "Content-Type": "application/json" },940 body: JSON.stringify({ category, message, version, browser, source: "peach-extension" }),941 });942 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };943 } catch (err) {944 return { ok: false, error: err instanceof Error ? err.message : "network error" };945 }946 }947 default:948 return { ok: false, error: "unknown message type" };949 }950 } catch (err) {951 return {952 ok: false,953 error: err instanceof Error ? err.message : "operation failed",954 };955 }956}957958959function broadcastToContentScripts(message: { type: string }): void {960 if (Capacitor.isNativePlatform() || !chrome.tabs?.query) return;961 chrome.tabs.query({}, (tabs) => {962 for (const tab of tabs) {963 if (tab.id !== undefined) {964 chrome.tabs.sendMessage(tab.id, message).catch(() => {965 966 });967 }968 }969 });970}971972function shouldRefreshAutoLock(type: MessageType): boolean {973 return (974 type !== "HAS_VAULT" &&975 type !== "IS_UNLOCKED" &&976 type !== "GET_VAULT_STATUS" &&977 type !== "LOCK" &&978 type !== "UPDATE_SETTINGS" &&979 type !== "GET_PENDING_PASSKEY" &&980 type !== "GET_PASSKEY_PROVIDER_STATE" &&981 type !== "DECLINE_CARD_SAVE" &&982 type !== "IS_CARD_DECLINED" &&983 type !== "SUBMIT_FEEDBACK"984 );985}986987function isAllowedCanaryWebhookUrl(value: string): boolean {988 try {989 const url = new URL(value);990 if (url.protocol !== "https:") return false;991992 const hostname = url.hostname.toLowerCase();993 if (994 hostname === "localhost" ||995 hostname.endsWith(".localhost") ||996 isPrivateIpv4(hostname) ||997 isPrivateIpv6(hostname)998 ) {999 return false;1000 }10011002 return true;1003 } catch {1004 return false;1005 }1006}10071008function isPrivateIpv4(hostname: string): boolean {1009 if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {1010 return true;1011 }10121013 const octets = hostname.split(".").map((part) => Number(part));1014 if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {1015 return false;1016 }10171018 return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;1019}10201021function isPrivateIpv6(hostname: string): boolean {1022 const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();1023 return (1024 normalized === "::1" ||1025 normalized.startsWith("fc") ||1026 normalized.startsWith("fd") ||1027 normalized.startsWith("fe80:")1028 );1029}