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