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}8485const PRIVATE_IPV4_RANGES = [86 { prefix: "10." },87 { prefix: "127." },88 { prefix: "169.254." },89 { prefix: "192.168." },90];919293949596const FEEDBACK_ENDPOINT = "";9798export function setupMessageHandler(): void {99 chrome.runtime.onMessage.addListener(100 (msg: unknown, sender, sendResponse) => {101 if (!isMessage(msg)) {102 sendResponse({ ok: false, error: "invalid message" });103 return true;104 }105 handleMessage(msg, sender)106 .then(sendResponse)107 .catch((err) =>108 sendResponse({109 ok: false,110 error: err instanceof Error ? err.message : String(err),111 })112 );113 return true;114 }115 );116}117118function isMessage(m: unknown): m is Message {119 return typeof m === "object" && m !== null && "type" in m;120}121122export async function handleMessage(123 msg: Message,124 sender: Partial<chrome.runtime.MessageSender> = {}125): Promise<MessageResponse> {126 try {127 128 129 130 131 if (132 !Capacitor.isNativePlatform() &&133 sender.id !== undefined &&134 sender.id !== chrome.runtime.id135 ) {136 return { ok: false, error: "invalid sender" };137 }138139 if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {140 touchAutoLock();141 }142143 switch (msg.type) {144 case "CREATE_VAULT": {145 const { password } = msg.payload as { password: string };146 await vault.createVault(password);147 await setupAutoLock();148 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });149 return { ok: true };150 }151 case "UNLOCK": {152 const { password } = msg.payload as { password: string };153 const ok = await vault.unlock(password);154 if (ok) {155 await setupAutoLock();156 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });157 }158 return { ok };159 }160 case "LOCK":161 vault.lock();162 return { ok: true };163 case "IS_UNLOCKED":164 return { ok: true, data: vault.isUnlocked() };165 case "GET_VAULT_STATUS":166 return { ok: true, unlocked: vault.isUnlocked() };167 case "HAS_VAULT":168 return { ok: true, data: await vault.hasVault() };169 case "GET_ENTRIES":170 return { ok: true, data: vault.getEntries() };171 case "ADD_ENTRY": {172 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };173 const entry = await vault.addEntry(msg.payload as any);174 return { ok: true, data: entry };175 }176 case "BATCH_ADD_ENTRIES": {177 const { entries } = msg.payload as { entries: any[] };178 const added = await vault.batchAddEntries(entries);179 return { ok: true, data: added };180 }181 case "RESTORE_ENTRIES": {182 const { entries, mode, settings } = msg.payload as {183 entries: any[];184 mode: "replace" | "merge";185 settings?: vault.VaultSettings;186 };187 const summary = await vault.restoreEntries(entries, mode);188 if (settings && typeof settings === "object") {189 await vault.updateSettings(settings);190 }191 return { ok: true, data: summary };192 }193 case "CLEAR_VAULT_ENTRIES": {194 await vault.clearVaultEntries("REPLACE_ALL");195 return { ok: true };196 }197 case "LIST_VAULT_SNAPSHOTS": {198 const snapshots = await vault.listSnapshots();199 return { ok: true, data: snapshots };200 }201 case "RESTORE_VAULT_SNAPSHOT": {202 const { index, password } = msg.payload as { index: number; password: string };203 const ok = await vault.restoreFromSnapshot(index, password);204 if (ok) broadcastToContentScripts({ type: "VAULT_UNLOCKED" });205 return { ok };206 }207 case "COUNT_DUPLICATES": {208 return { ok: true, data: vault.countDuplicates() };209 }210 case "DEDUPLICATE_ENTRIES": {211 const removed = await vault.deduplicateEntries();212 return { ok: true, data: removed };213 }214 case "UPDATE_ENTRY": {215 const { id, patch } = msg.payload as any;216 await vault.updateEntry(id, patch);217 return { ok: true };218 }219 case "ADD_ADDITIONAL_URL": {220 const { id, url } = msg.payload as { id: string; url: string };221 const trimmed = typeof url === "string" ? url.trim() : "";222 if (!trimmed) return { ok: false, error: "url required" };223 const existing = vault.getEntryById(id);224 if (!existing) return { ok: false, error: "entry not found" };225 if (existing.type !== "login") return { ok: false, error: "login entries only" };226 const merged = [...(existing.additionalUrls ?? []), trimmed];227 await vault.updateEntry(id, { additionalUrls: merged });228 return { ok: true };229 }230 case "DELETE_ENTRY": {231 const { id } = msg.payload as { id: string };232 await vault.deleteEntry(id);233 return { ok: true };234 }235 case "GET_DELETED_ENTRIES":236 return { ok: true, data: vault.getDeletedEntries() };237 case "RESTORE_ENTRY": {238 const { id } = msg.payload as { id: string };239 await vault.restoreEntry(id);240 return { ok: true };241 }242 case "PURGE_ENTRY": {243 const { id } = msg.payload as { id: string };244 await vault.purgeEntry(id);245 return { ok: true };246 }247 case "PURGE_EXPIRED_TRASH": {248 const { retentionMs } = msg.payload as { retentionMs: number };249 const purged = await vault.purgeExpiredTrash(retentionMs);250 return { ok: true, data: purged };251 }252 case "AUTOFILL_QUERY": {253 if (!vault.isUnlocked()) {254 return { ok: false, error: "VAULT_LOCKED" };255 }256 const { domain, includeAll, kind } = msg.payload as {257 domain: string;258 includeAll?: boolean;259 kind?: "login" | "card";260 };261 262 263 264 265 if (kind === "card") {266 const cards = vault.getEntries().filter((e) => e.type === "card");267 return {268 ok: true,269 data: cards.map((e) => ({270 id: e.id,271 title: e.cardNickname || e.title,272 cardholderName: e.cardholderName,273 last4: e.last4,274 expMonth: e.expMonth,275 expYear: e.expYear,276 network: e.network,277 })),278 };279 }280 281 282 283 284 285 const matches = (286 includeAll287 ? vault288 .getEntries()289 .filter((entry) => entry.type !== "note" && entry.type !== "card")290 : vault.queryDomain(domain)291 ).filter((entry) => !isPasskeyOnly(entry));292 return {293 ok: true,294 data: matches.map((e) =>295 includeAll296 ? {297 id: e.id,298 title: e.title,299 hasPasskey: e.passkey !== undefined,300 }301 : {302 id: e.id,303 title: e.title,304 username: e.username,305 url: e.url,306 hasTotp: Boolean(e.totp),307 hasPasskey: e.passkey !== undefined,308 }309 ),310 };311 }312 case "GET_ENTRY_FOR_FILL": {313 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };314 const { id } = msg.payload as { id: string };315 const entry = vault.getEntryById(id);316 if (!entry) return { ok: false, error: "entry not found" };317 318 319 320 if (isPasskeyOnly(entry)) return { ok: false, error: "passkey not fillable" };321 322 323 324 const { passkey: _strippedPasskey, ...safeEntry } = entry;325 return { ok: true, data: safeEntry };326 }327 case "COPY": {328 const { text, label, alreadyWritten } = msg.payload as {329 text: string;330 label: string;331 alreadyWritten?: boolean;332 };333 await copyToClipboard(text, label, Boolean(alreadyWritten));334 return { ok: true };335 }336 case "CHECK_BREACH": {337 const { password } = msg.payload as { password: string };338 const count = await vault.checkBreach(password);339 return { ok: true, data: count };340 }341 case "CHECK_ALL_BREACHES": {342 const allEntries = vault.getEntries();343 const results: Record<string, number> = {};344 for (const entry of allEntries) {345 if (entry.type === "note") continue;346 if (!entry.password) continue;347 results[entry.id] = await vault.checkBreach(entry.password);348 349 await new Promise((r) => setTimeout(r, 200));350 }351 352 for (const [id, count] of Object.entries(results)) {353 const entry = vault.getEntryById(id);354 if (entry && (entry.breach ?? 0) !== count) {355 await vault.updateEntry(id, { breach: count > 0 ? count : undefined });356 }357 }358 return { ok: true, data: results };359 }360 case "GET_SETTINGS":361 return { ok: true, data: await vault.ensureSettingsLoaded() };362 case "UPDATE_SETTINGS": {363 const patch = msg.payload as Record<string, unknown>;364 await vault.updateSettings(patch as any);365 if (typeof patch.autoLockMinutes === "number") {366 await setupAutoLock();367 if (vault.isUnlocked()) touchAutoLock();368 }369 return { ok: true };370 }371 case "GET_PENDING_PASSKEY":372 return { ok: true, data: await getPendingPasskeyRequest() };373 case "APPROVE_PASSKEY": {374 const { requestId } = msg.payload as { requestId: number };375 await resolvePendingPasskeyRequest(requestId, true);376 return { ok: true };377 }378 case "DENY_PASSKEY": {379 const { requestId } = msg.payload as { requestId: number };380 await resolvePendingPasskeyRequest(requestId, false);381 return { ok: true };382 }383 case "GET_PASSKEYS":384 return {385 ok: true,386 data: vault.getEntries().filter((entry) => entry.passkey !== undefined),387 };388 case "DELETE_PASSKEY": {389 const { id } = msg.payload as { id: string };390 await vault.deleteEntry(id);391 return { ok: true };392 }393 case "GET_PASSKEY_PROVIDER_STATE":394 return { ok: true, data: await getPasskeyProviderState() };395 case "SET_PASSKEY_PROVIDER_ENABLED": {396 const { enabled } = msg.payload as { enabled: boolean };397 398 399 if (enabled) {400 const gate = await requirePro("passkeys");401 if (!gate.allowed) return { ok: false, error: "pro_required" };402 }403 return { ok: true, data: await setPasskeyProviderEnabled(enabled) };404 }405 case "FIRE_CANARY_PING": {406 const { webhookUrl, entryTitle, trigger } = msg.payload as {407 webhookUrl: string;408 entryTitle: string;409 trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";410 };411 if (!isAllowedCanaryWebhookUrl(webhookUrl)) {412 return { ok: false, error: "invalid webhook url" };413 }414 const body = JSON.stringify({415 event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",416 entry: entryTitle,417 trigger,418 timestamp: new Date().toISOString(),419 source: "peach-extension",420 });421 if (trigger === "test") {422 423 424 425 426 427 try {428 const res = await fetch(webhookUrl, {429 method: "POST",430 headers: { "Content-Type": "application/json" },431 body,432 });433 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };434 } catch (err) {435 return { ok: false, error: err instanceof Error ? err.message : "network error" };436 }437 }438 439 fetch(webhookUrl, {440 method: "POST",441 headers: { "Content-Type": "application/json" },442 body,443 }).catch(() => {});444 return { ok: true };445 }446 case "ACTIVATE_LICENSE": {447 const { key } = msg.payload as { key: string };448 const licenseInfo = await activateLicense(key);449 return { ok: true, data: licenseInfo };450 }451 case "GET_LICENSE": {452 const licenseInfo = await getLicenseInfo();453 return { ok: true, data: licenseInfo };454 }455 case "GET_LICENSE_KEY": {456 const key = await getLicenseKey();457 return { ok: true, data: key };458 }459 case "DEACTIVATE_LICENSE": {460 await deactivateLicense();461 return { ok: true };462 }463 case "SHOULD_PLAY_CELEBRATION": {464 const should = await shouldPlayCelebration();465 return { ok: true, data: should };466 }467 case "MARK_CELEBRATION_PLAYED": {468 await markCelebrationPlayed();469 return { ok: true };470 }471 case "OPEN_PRO_GATE": {472 473 const { feature } = (msg.payload ?? {}) as { feature?: string };474 if (chrome.storage.session) {475 await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });476 }477 try { await chrome.action.openPopup(); } catch { }478 return { ok: true };479 }480 case "GET_IDENTITIES": {481 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };482 return { ok: true, data: vault.getIdentities() };483 }484 case "GET_ADVANCED_HEALTH_REPORT": {485 const gate = await requirePro("advanced_health");486 if (!gate.allowed) return { ok: false, error: "pro_required" };487 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };488 const report = await buildAdvancedHealthReport(vault.getEntries());489 return { ok: true, data: report };490 }491 case "INITIATE_PASSWORD_CHANGE": {492 const gate = await requirePro("smart_password_change");493 if (!gate.allowed) return { ok: false, error: "pro_required" };494 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };495 const { id } = msg.payload as { id: string };496 const entry = vault.getEntryById(id);497 if (!entry) return { ok: false, error: "entry not found" };498 const url = resolveResetUrl(entry);499 if (!url) return { ok: false, error: "no reset url" };500 try {501 await chrome.tabs.create({ url });502 return { ok: true, data: { url } };503 } catch (err) {504 return {505 ok: false,506 error: err instanceof Error ? err.message : "failed to open tab",507 };508 }509 }510 case "IS_DURESS_ENABLED": {511 const enabled = await vault.isDuressEnabled();512 return { ok: true, data: enabled };513 }514 case "SETUP_DURESS": {515 const { duressPassword } = msg.payload as { duressPassword: string };516 await vault.setupDuress(duressPassword);517 return { ok: true };518 }519 case "DISABLE_DURESS": {520 await vault.disableDuress();521 return { ok: true };522 }523 case "VERIFY_MAIN_PASSWORD": {524 const { password } = msg.payload as { password: string };525 const ok = await vault.verifyMainPassword(password);526 return { ok: true, data: ok };527 }528 case "CHANGE_PASSWORD": {529 const { currentPassword, newPassword } = msg.payload as {530 currentPassword: string;531 newPassword: string;532 };533 const changed = await vault.changePassword(currentPassword, newPassword);534 return changed535 ? { ok: true }536 : { ok: false, error: "incorrect current password" };537 }538 case "GET_SITE_PASSWORD_POLICY": {539 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });540 if (!tab?.id) return { ok: true, data: null };541 try {542 const response = await chrome.tabs.sendMessage(tab.id, {543 type: "GET_SITE_PASSWORD_POLICY",544 });545 return (response as MessageResponse) ?? { ok: true, data: null };546 } catch {547 return { ok: true, data: null };548 }549 }550 case "GET_SYNC_STATE": {551 const { getPairedDevices, getTrustedNetworks } = await import('./sync-storage');552 const { isOnTailscale } = await import('./sync-network');553 const { isProUser } = await import('./license');554 const pairedDevices = await getPairedDevices();555 const trustedNetworks = await getTrustedNetworks();556 const tailscale = await isOnTailscale();557 const pro = await isProUser();558 559 let status: SyncStatus = "offline";560 if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {561 if (pairedDevices.length === 0) {562 status = "no_peers";563 } else {564 status = "synced";565 }566 } else {567 status = "not_trusted";568 }569 570 return { ok: true, data: { pairedDevices, trustedNetworks, status, isPro: pro } };571 }572 case "TRIGGER_SYNC": {573 const gate = await requirePro("sync");574 if (!gate.allowed) return { ok: false, error: "pro_required" };575 return { ok: true };576 }577 case "START_PAIRING": {578 const gate = await requirePro("sync");579 if (!gate.allowed) return { ok: false, error: "pro_required" };580 return { ok: true };581 }582 case "CANCEL_PAIRING": {583 return { ok: true };584 }585 case "ACCEPT_PAIRING": {586 const gate = await requirePro("sync");587 if (!gate.allowed) return { ok: false, error: "pro_required" };588 return { ok: true };589 }590 case "REMOVE_PAIRED_DEVICE": {591 const { deviceId } = msg.payload as { deviceId: string };592 const { removePairedDevice } = await import('./sync-storage');593 await removePairedDevice(deviceId);594 return { ok: true };595 }596 case "REMOVE_TRUSTED_NETWORK": {597 const { ssid, bssid, isTailscale } = msg.payload as any;598 const { removeTrustedNetwork, getTrustedNetworks } = await import('./sync-storage');599 const networks = await getTrustedNetworks();600 const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 601 (isTailscale ? n.isTailscale : n.ssid === ssid) && 602 (!bssid || n.bssid === bssid)603 );604 if (network) {605 await removeTrustedNetwork(network.addedAt);606 }607 return { ok: true };608 }609 case "MARK_NETWORK_TRUSTED": {610 const { addTrustedNetwork } = await import('./sync-storage');611 const { getCurrentNetwork } = await import('./sync-network');612 const network = await getCurrentNetwork();613 await addTrustedNetwork({614 ssid: network.ssid,615 bssid: network.bssid,616 isTailscale: network.isTailscale,617 addedAt: Date.now(),618 });619 return { ok: true };620 }621 case "GET_INCOMING_PAIRING_REQUEST": {622 const { getIncomingPairingRequest } = await import('./sync-storage');623 const request = await getIncomingPairingRequest();624 return { ok: true, data: request };625 }626 case "RESPOND_INCOMING_PAIRING": {627 const { accept } = msg.payload as { accept: boolean };628 const { getIncomingPairingRequest, clearIncomingPairingRequest } = await import('./sync-storage');629 const request = await getIncomingPairingRequest();630 if (!request) return { ok: false, error: "no_pending_request" };631 await clearIncomingPairingRequest();632 if (accept) {633 634 635 636 637 try {638 const { trustDevice } = await import('./sync');639 await trustDevice(request.publicKeyHex, request.deviceName);640 } catch {641 642 }643 }644 return { ok: true, data: { accepted: accept, deviceName: request.deviceName } };645 }646 case "DEBUG_INJECT_INCOMING_PAIRING": {647 648 649 650 651 const { deviceName, publicKeyHex } = (msg.payload ?? {}) as {652 deviceName?: string;653 publicKeyHex?: string;654 };655 if (!deviceName || !publicKeyHex) {656 return { ok: false, error: "missing_payload" };657 }658 const { setIncomingPairingRequest } = await import('./sync-storage');659 const now = Date.now();660 await setIncomingPairingRequest({661 deviceName,662 publicKeyHex,663 receivedAt: now,664 expiresAt: now + 60_000,665 });666 return { ok: true };667 }668 case "RESET_SYNC_IDENTITY": {669 const gate = await requirePro("sync");670 if (!gate.allowed) return { ok: false, error: "pro_required" };671 const { getPairedDevices, removePairedDevice } = await import('./sync-storage');672 const devices = await getPairedDevices();673 for (const device of devices) {674 await removePairedDevice(device.deviceId);675 }676 677 678 679 680 let nativePublicKey: string | null = null;681 try {682 const { rotateNativeIdentity, disconnectSyncHost } = await import('./sync');683 nativePublicKey = await rotateNativeIdentity();684 disconnectSyncHost();685 } catch {686 nativePublicKey = null;687 }688 return { ok: true, data: { removedDevices: devices.length, nativePublicKey } };689 }690 case "RECORD_MANUAL_SUMMON": {691 const { domain } = msg.payload as { domain: string };692 const settings = vault.getSettings();693 const existing = settings.manualSummonDomains?.[domain];694 const now = Date.now();695 await vault.updateSettings({696 manualSummonDomains: {697 ...settings.manualSummonDomains,698 [domain]: {699 firstSummonedAt: existing?.firstSummonedAt ?? now,700 summonCount: (existing?.summonCount ?? 0) + 1,701 lastSummonedAt: now,702 },703 },704 });705 return { ok: true };706 }707 case "DECLINE_CARD_SAVE": {708 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };709 await vault.declineCardSaveOffer(last4, expMonth, expYear);710 return { ok: true };711 }712 case "IS_CARD_DECLINED": {713 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };714 const declined = await vault.isCardDeclined(last4, expMonth, expYear);715 return { ok: true, data: declined };716 }717 case "INITIATE_FIREFOX_PASSKEY_CREATE": {718 const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {719 rpId?: string;720 rpName?: string;721 userName?: string;722 userDisplayName?: string;723 };724 if (!rpId || !userName) return { ok: false, error: "rpId and userName required" };725 return await initiateFirefoxPasskeyCreate({726 rpId,727 rpName: rpName ?? rpId,728 userName,729 userDisplayName,730 });731 }732733 case "COMPLETE_FIREFOX_PASSKEY_CREATE": {734 const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;735 if (!payload) return { ok: false, error: "missing payload" };736 return await completeFirefoxPasskeyCreate(payload);737 }738739 case "PASSKEY_OFFER_DECLINE": {740 const { domain } = (msg.payload ?? {}) as { domain?: string };741 if (!domain) return { ok: false, error: "domain required" };742 await recordPasskeyOfferDecline(domain);743 return { ok: true };744 }745746 case "GET_PASSKEY_OFFER_DECLINED": {747 const { domain } = (msg.payload ?? {}) as { domain?: string };748 if (!domain) return { ok: false, error: "domain required" };749 return { ok: true, data: await isPasskeyOfferDeclined(domain) };750 }751752 case "PASSKEY_HAS_CREDENTIALS": {753 const payload = msg.payload as {754 rpId?: string;755 allowCredentials?: Array<{ id: string; type?: string }>;756 } | undefined;757 if (!payload?.rpId) return { ok: false, error: "rpId required" };758 return await handlePasskeyHasCredentials({759 rpId: payload.rpId,760 allowCredentials: payload.allowCredentials ?? [],761 });762 }763764 case "PASSKEY_CREATE": {765 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;766 if (!payload?.rpId || !payload?.userName) {767 return { ok: false, error: "rpId and userName required" };768 }769 return await handleFirefoxPasskeyCreate(payload);770 }771772 case "PASSKEY_GET": {773 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;774 if (!payload?.rpId) return { ok: false, error: "rpId required" };775 return await handleFirefoxPasskeyGet(payload);776 }777778 case "GET_PASSKEY_FOR_DOMAIN": {779 const { domain } = (msg.payload ?? {}) as { domain?: string };780 if (!domain) return { ok: false, error: "domain required" };781 if (!vault.isUnlocked()) return { ok: true, data: null };782 const match = vault783 .getEntries()784 .find((entry) => entry.passkey?.rpId === domain);785 if (!match || !match.passkey) return { ok: true, data: null };786 787 788 return {789 ok: true,790 data: {791 rpId: match.passkey.rpId,792 userName: match.passkey.userName,793 browser: match.passkey.browser,794 },795 };796 }797798 case "DESTROY_VAULT": {799 800 801 802 803 804 vault.lock();805 await storageClear();806 if (!Capacitor.isNativePlatform() && chrome.storage.session) {807 await chrome.storage.session.clear();808 }809 return { ok: true };810 }811812 case "SUBMIT_FEEDBACK": {813 const { category, message, version, browser } = msg.payload as {814 category: string;815 message: string;816 version: string;817 browser: string;818 };819 if (!message.trim()) return { ok: false, error: "message is empty" };820 if (!FEEDBACK_ENDPOINT) return { ok: true };821 if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {822 return { ok: false, error: "invalid feedback endpoint" };823 }824 try {825 const res = await fetch(FEEDBACK_ENDPOINT, {826 method: "POST",827 headers: { "Content-Type": "application/json" },828 body: JSON.stringify({ category, message, version, browser, source: "peach-extension" }),829 });830 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };831 } catch (err) {832 return { ok: false, error: err instanceof Error ? err.message : "network error" };833 }834 }835 default:836 return { ok: false, error: "unknown message type" };837 }838 } catch (err) {839 return {840 ok: false,841 error: err instanceof Error ? err.message : "operation failed",842 };843 }844}845846847function broadcastToContentScripts(message: { type: string }): void {848 if (Capacitor.isNativePlatform() || !chrome.tabs?.query) return;849 chrome.tabs.query({}, (tabs) => {850 for (const tab of tabs) {851 if (tab.id !== undefined) {852 chrome.tabs.sendMessage(tab.id, message).catch(() => {853 854 });855 }856 }857 });858}859860function shouldRefreshAutoLock(type: MessageType): boolean {861 return (862 type !== "HAS_VAULT" &&863 type !== "IS_UNLOCKED" &&864 type !== "GET_VAULT_STATUS" &&865 type !== "LOCK" &&866 type !== "UPDATE_SETTINGS" &&867 type !== "GET_PENDING_PASSKEY" &&868 type !== "GET_PASSKEY_PROVIDER_STATE" &&869 type !== "DECLINE_CARD_SAVE" &&870 type !== "IS_CARD_DECLINED" &&871 type !== "SUBMIT_FEEDBACK"872 );873}874875function isAllowedCanaryWebhookUrl(value: string): boolean {876 try {877 const url = new URL(value);878 if (url.protocol !== "https:") return false;879880 const hostname = url.hostname.toLowerCase();881 if (882 hostname === "localhost" ||883 hostname.endsWith(".localhost") ||884 isPrivateIpv4(hostname) ||885 isPrivateIpv6(hostname)886 ) {887 return false;888 }889890 return true;891 } catch {892 return false;893 }894}895896function isPrivateIpv4(hostname: string): boolean {897 if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {898 return true;899 }900901 const octets = hostname.split(".").map((part) => Number(part));902 if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {903 return false;904 }905906 return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;907}908909function isPrivateIpv6(hostname: string): boolean {910 const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();911 return (912 normalized === "::1" ||913 normalized.startsWith("fc") ||914 normalized.startsWith("fd") ||915 normalized.startsWith("fe80:")916 );917}