123456789101112import { copyToClipboard } from "./clipboard";13import { setupAutoLock, touchAutoLock } from "./alarms";14import * as vault from "./vault";15import * as syncLifecycle from "./sync-lifecycle";16import {17 activateLicense,18 deactivateLicense,19 getLicenseInfo,20 getLicenseKey,21 isProUser,22 markCelebrationPlayed,23 shouldPlayCelebration,24} from "./license";25import {26 cancelFirefoxPasskeyCreate,27 completeFirefoxPasskeyCreate,28 getPasskeyProviderState,29 getPendingPasskeyRequest,30 handleFirefoxPasskeyCreate,31 handleFirefoxPasskeyGet,32 handlePasskeyHasCredentials,33 initiateFirefoxPasskeyCreate,34 isPasskeyOfferDeclined,35 recordPasskeyOfferDecline,36 resolvePendingPasskeyRequest,37 setPasskeyProviderEnabled,38} from "./passkey";39import { buildAdvancedHealthReport, resolveResetUrl } from "./health";40import { Capacitor } from "@capacitor/core";41import { storageClear } from "../shared/storage";42import type {43 Message,44 MessageResponse,45 MessageType,46 ProFeature,47} from "../shared/types";48import { isPasskeyOnly } from "../shared/types";49import type { SyncStatus } from "../shared/sync-types";5051525354555657async function requirePro(58 feature: ProFeature,59 trigger: "user" | "background" = "user"60): Promise<{ allowed: boolean }> {61 if (await isProUser()) return { allowed: true };6263 if (trigger === "user") {64 65 if (!Capacitor.isNativePlatform()) {66 chrome.runtime67 .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })68 .catch(() => {69 70 });71 }72 } else {73 74 75 76 if (!Capacitor.isNativePlatform() && chrome.storage.session) {77 chrome.storage.session78 .set({ pendingProNotice: { feature, triggeredAt: Date.now() } })79 .catch(() => {});80 }81 }8283 return { allowed: false };84}858687888990const DEVICE_NAME_KEY = "peach_device_name";9192async function getLocalDeviceName(): Promise<string> {93 try {94 const stored = await chrome.storage.local.get(DEVICE_NAME_KEY);95 const value = (stored as Record<string, string | undefined>)[DEVICE_NAME_KEY];96 if (typeof value === "string" && value.trim()) return value.trim();97 } catch {98 99 }100 if (Capacitor.getPlatform() === "android") return "Peach Android";101 if (Capacitor.getPlatform() === "ios") return "Peach iOS";102 return "Peach";103}104105106107108109110111112113114115let peerDiscoverySubscriptions: Array<() => void> = [];116117async function startPeerDiscoveryForPopup(): Promise<void> {118 await stopPeerDiscoveryForPopup();119 const sync = await import('./sync');120 const { getPairedDevices } = await import('./sync-storage');121 sync.connectSyncHost();122123 const unsubDiscovered = sync.onPeerDiscovered((peer) => {124 void (async () => {125 const paired = await getPairedDevices();126 const alreadyPaired = paired.some((d) => d.deviceId === peer.deviceId);127 if (Capacitor.isNativePlatform()) return;128 chrome.runtime129 .sendMessage({130 type: "SYNC_PEER_DISCOVERED",131 peer: {132 deviceId: peer.deviceId,133 deviceName: peer.name,134 host: peer.host,135 port: peer.port,136 wsPort: peer.wsPort,137 x25519PublicKey: peer.x25519PublicKey,138 alreadyPaired,139 },140 })141 .catch(() => {});142 })();143 });144 const unsubLost = sync.onPeerLost((deviceId) => {145 if (Capacitor.isNativePlatform()) return;146 chrome.runtime147 .sendMessage({ type: "SYNC_PEER_LOST", deviceId })148 .catch(() => {});149 });150 peerDiscoverySubscriptions = [unsubDiscovered, unsubLost];151 sync.discoverPeers();152}153154async function stopPeerDiscoveryForPopup(): Promise<void> {155 for (const unsub of peerDiscoverySubscriptions) unsub();156 peerDiscoverySubscriptions = [];157 try {158 const sync = await import('./sync');159 if (sync.isHostConnected()) {160 await sync.stopDiscovery();161 }162 } catch {163 164 }165}166167const PRIVATE_IPV4_RANGES = [168 { prefix: "10." },169 { prefix: "127." },170 { prefix: "169.254." },171 { prefix: "192.168." },172];173174175176177178const FEEDBACK_ENDPOINT = "";179180export function setupMessageHandler(): void {181 chrome.runtime.onMessage.addListener(182 (msg: unknown, sender, sendResponse) => {183 if (!isMessage(msg)) {184 sendResponse({ ok: false, error: "invalid message" });185 return true;186 }187 handleMessage(msg, sender)188 .then(sendResponse)189 .catch((err) =>190 sendResponse({191 ok: false,192 error: err instanceof Error ? err.message : String(err),193 })194 );195 return true;196 }197 );198}199200function isMessage(m: unknown): m is Message {201 return typeof m === "object" && m !== null && "type" in m;202}203204export async function handleMessage(205 msg: Message,206 sender: Partial<chrome.runtime.MessageSender> = {}207): Promise<MessageResponse> {208 try {209 210 211 212 213 if (214 !Capacitor.isNativePlatform() &&215 sender.id !== undefined &&216 sender.id !== chrome.runtime.id217 ) {218 return { ok: false, error: "invalid sender" };219 }220221 if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {222 touchAutoLock();223 }224225 switch (msg.type) {226 case "CREATE_VAULT": {227 const { password } = msg.payload as { password: string };228 await vault.createVault(password);229 await setupAutoLock();230 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });231 void syncLifecycle.onVaultUnlocked();232 return { ok: true };233 }234 case "UNLOCK": {235 const { password } = msg.payload as { password: string };236 const ok = await vault.unlock(password);237 if (ok) {238 await setupAutoLock();239 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });240 void syncLifecycle.onVaultUnlocked();241 }242 return { ok };243 }244 case "LOCK":245 vault.lock();246 void syncLifecycle.onVaultLocked();247 return { ok: true };248 case "IS_UNLOCKED":249 return { ok: true, data: vault.isUnlocked() };250 case "GET_VAULT_STATUS":251 return { ok: true, unlocked: vault.isUnlocked() };252 case "HAS_VAULT":253 return { ok: true, data: await vault.hasVault() };254 case "GET_ENTRIES":255 return { ok: true, data: vault.getEntries() };256 case "ADD_ENTRY": {257 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };258 const entry = await vault.addEntry(msg.payload as any);259 return { ok: true, data: entry };260 }261 case "BATCH_ADD_ENTRIES": {262 const { entries } = msg.payload as { entries: any[] };263 const added = await vault.batchAddEntries(entries);264 return { ok: true, data: added };265 }266 case "RESTORE_ENTRIES": {267 const { entries, mode, settings } = msg.payload as {268 entries: any[];269 mode: "replace" | "merge";270 settings?: vault.VaultSettings;271 };272 const summary = await vault.restoreEntries(entries, mode);273 if (settings && typeof settings === "object") {274 await vault.updateSettings(settings);275 }276 return { ok: true, data: summary };277 }278 case "CLEAR_VAULT_ENTRIES": {279 await vault.clearVaultEntries("REPLACE_ALL");280 return { ok: true };281 }282 case "GET_VAULT_HISTORY": {283 const snapshots = await vault.getVaultHistory();284 return { ok: true, data: snapshots };285 }286 case "RESTORE_VAULT_SNAPSHOT": {287 const { snapshotId } = msg.payload as { snapshotId: string };288 const result = await vault.restoreVaultSnapshot(snapshotId);289 if (!result.ok) return { ok: false, error: result.error };290 broadcastToContentScripts({ type: "VAULT_LOCKED" });291 return { ok: true };292 }293 case "COUNT_DUPLICATES": {294 return { ok: true, data: vault.countDuplicates() };295 }296 case "DEDUPLICATE_ENTRIES": {297 const removed = await vault.deduplicateEntries();298 return { ok: true, data: removed };299 }300 case "UPDATE_ENTRY": {301 const { id, patch } = msg.payload as any;302 await vault.updateEntry(id, patch);303 return { ok: true };304 }305 case "ADD_ADDITIONAL_URL": {306 const { id, url } = msg.payload as { id: string; url: string };307 const trimmed = typeof url === "string" ? url.trim() : "";308 if (!trimmed) return { ok: false, error: "url required" };309 const existing = vault.getEntryById(id);310 if (!existing) return { ok: false, error: "entry not found" };311 if (existing.type !== "login") return { ok: false, error: "login entries only" };312 const merged = [...(existing.additionalUrls ?? []), trimmed];313 await vault.updateEntry(id, { additionalUrls: merged });314 return { ok: true };315 }316 case "DELETE_ENTRY": {317 const { id } = msg.payload as { id: string };318 await vault.deleteEntry(id);319 return { ok: true };320 }321 case "GET_DELETED_ENTRIES":322 return { ok: true, data: vault.getDeletedEntries() };323 case "RESTORE_ENTRY": {324 const { id } = msg.payload as { id: string };325 await vault.restoreEntry(id);326 return { ok: true };327 }328 case "PURGE_ENTRY": {329 const { id } = msg.payload as { id: string };330 await vault.purgeEntry(id);331 return { ok: true };332 }333 case "PURGE_EXPIRED_TRASH": {334 const { retentionMs } = msg.payload as { retentionMs: number };335 const purged = await vault.purgeExpiredTrash(retentionMs);336 return { ok: true, data: purged };337 }338 case "AUTOFILL_QUERY": {339 if (!vault.isUnlocked()) {340 return { ok: false, error: "VAULT_LOCKED" };341 }342 const { domain, includeAll, kind } = msg.payload as {343 domain: string;344 includeAll?: boolean;345 kind?: "login" | "card";346 };347 348 349 350 351 if (kind === "card") {352 const cards = vault.getEntries().filter((e) => e.type === "card");353 return {354 ok: true,355 data: cards.map((e) => ({356 id: e.id,357 title: e.cardNickname || e.title,358 cardholderName: e.cardholderName,359 last4: e.last4,360 expMonth: e.expMonth,361 expYear: e.expYear,362 network: e.network,363 })),364 };365 }366 367 368 369 370 371 const matches = (372 includeAll373 ? vault374 .getEntries()375 .filter((entry) => entry.type !== "note" && entry.type !== "card")376 : vault.queryDomain(domain)377 ).filter((entry) => !isPasskeyOnly(entry));378 return {379 ok: true,380 data: matches.map((e) =>381 includeAll382 ? {383 id: e.id,384 title: e.title,385 hasPasskey: e.passkey !== undefined,386 }387 : {388 id: e.id,389 title: e.title,390 username: e.username,391 url: e.url,392 hasTotp: Boolean(e.totp),393 hasPasskey: e.passkey !== undefined,394 }395 ),396 };397 }398 case "GET_ENTRY_FOR_FILL": {399 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };400 const { id } = msg.payload as { id: string };401 const entry = vault.getEntryById(id);402 if (!entry) return { ok: false, error: "entry not found" };403 404 405 406 if (isPasskeyOnly(entry)) return { ok: false, error: "passkey not fillable" };407 408 409 410 const { passkey: _strippedPasskey, ...safeEntry } = entry;411 return { ok: true, data: safeEntry };412 }413 case "COPY": {414 const { text, label, alreadyWritten } = msg.payload as {415 text: string;416 label: string;417 alreadyWritten?: boolean;418 };419 await copyToClipboard(text, label, Boolean(alreadyWritten));420 return { ok: true };421 }422 case "CHECK_BREACH": {423 const { password } = msg.payload as { password: string };424 const count = await vault.checkBreach(password);425 return { ok: true, data: count };426 }427 case "CHECK_ALL_BREACHES": {428 const allEntries = vault.getEntries();429 const results: Record<string, number> = {};430 for (const entry of allEntries) {431 if (entry.type === "note") continue;432 if (!entry.password) continue;433 results[entry.id] = await vault.checkBreach(entry.password);434 435 await new Promise((r) => setTimeout(r, 200));436 }437 438 for (const [id, count] of Object.entries(results)) {439 const entry = vault.getEntryById(id);440 if (entry && (entry.breach ?? 0) !== count) {441 await vault.updateEntry(id, { breach: count > 0 ? count : undefined });442 }443 }444 return { ok: true, data: results };445 }446 case "GET_SETTINGS":447 return { ok: true, data: await vault.ensureSettingsLoaded() };448 case "UPDATE_SETTINGS": {449 const patch = msg.payload as Record<string, unknown>;450 await vault.updateSettings(patch as any);451 if (typeof patch.autoLockMinutes === "number") {452 await setupAutoLock();453 if (vault.isUnlocked()) touchAutoLock();454 }455 return { ok: true };456 }457 case "GET_PENDING_PASSKEY":458 return { ok: true, data: await getPendingPasskeyRequest() };459 case "APPROVE_PASSKEY": {460 const { requestId } = msg.payload as { requestId: number };461 await resolvePendingPasskeyRequest(requestId, true);462 return { ok: true };463 }464 case "DENY_PASSKEY": {465 const { requestId } = msg.payload as { requestId: number };466 await resolvePendingPasskeyRequest(requestId, false);467 return { ok: true };468 }469 case "GET_PASSKEYS":470 return {471 ok: true,472 data: vault.getEntries().filter((entry) => entry.passkey !== undefined),473 };474 case "DELETE_PASSKEY": {475 const { id } = msg.payload as { id: string };476 await vault.deleteEntry(id);477 return { ok: true };478 }479 case "GET_PASSKEY_PROVIDER_STATE":480 return { ok: true, data: await getPasskeyProviderState() };481 case "SET_PASSKEY_PROVIDER_ENABLED": {482 const { enabled } = msg.payload as { enabled: boolean };483 484 485 if (enabled) {486 const gate = await requirePro("passkeys");487 if (!gate.allowed) return { ok: false, error: "pro_required" };488 }489 return { ok: true, data: await setPasskeyProviderEnabled(enabled) };490 }491 case "FIRE_CANARY_PING": {492 const { webhookUrl, entryTitle, trigger } = msg.payload as {493 webhookUrl: string;494 entryTitle: string;495 trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";496 };497 if (!isAllowedCanaryWebhookUrl(webhookUrl)) {498 return { ok: false, error: "invalid webhook url" };499 }500 const body = JSON.stringify({501 event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",502 entry: entryTitle,503 trigger,504 timestamp: new Date().toISOString(),505 source: "peach-extension",506 });507 if (trigger === "test") {508 509 510 511 512 513 try {514 const res = await fetch(webhookUrl, {515 method: "POST",516 headers: { "Content-Type": "application/json" },517 body,518 });519 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };520 } catch (err) {521 return { ok: false, error: err instanceof Error ? err.message : "network error" };522 }523 }524 525 fetch(webhookUrl, {526 method: "POST",527 headers: { "Content-Type": "application/json" },528 body,529 }).catch(() => {});530 return { ok: true };531 }532 case "ACTIVATE_LICENSE": {533 const { key } = msg.payload as { key: string };534 const licenseInfo = await activateLicense(key);535 return { ok: true, data: licenseInfo };536 }537 case "GET_LICENSE": {538 const licenseInfo = await getLicenseInfo();539 return { ok: true, data: licenseInfo };540 }541 case "GET_LICENSE_KEY": {542 const key = await getLicenseKey();543 return { ok: true, data: key };544 }545 case "DEACTIVATE_LICENSE": {546 await deactivateLicense();547 return { ok: true };548 }549 case "SHOULD_PLAY_CELEBRATION": {550 const should = await shouldPlayCelebration();551 return { ok: true, data: should };552 }553 case "MARK_CELEBRATION_PLAYED": {554 await markCelebrationPlayed();555 return { ok: true };556 }557 case "OPEN_PRO_GATE": {558 559 const { feature } = (msg.payload ?? {}) as { feature?: string };560 if (chrome.storage.session) {561 await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });562 }563 try { await chrome.action.openPopup(); } catch { }564 return { ok: true };565 }566 case "GET_IDENTITIES": {567 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };568 return { ok: true, data: vault.getIdentities() };569 }570 case "GET_ADVANCED_HEALTH_REPORT": {571 const gate = await requirePro("advanced_health");572 if (!gate.allowed) return { ok: false, error: "pro_required" };573 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };574 const report = await buildAdvancedHealthReport(vault.getEntries());575 return { ok: true, data: report };576 }577 case "INITIATE_PASSWORD_CHANGE": {578 const gate = await requirePro("smart_password_change");579 if (!gate.allowed) return { ok: false, error: "pro_required" };580 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };581 const { id } = msg.payload as { id: string };582 const entry = vault.getEntryById(id);583 if (!entry) return { ok: false, error: "entry not found" };584 const url = resolveResetUrl(entry);585 if (!url) return { ok: false, error: "no reset url" };586 try {587 await chrome.tabs.create({ url });588 return { ok: true, data: { url } };589 } catch (err) {590 return {591 ok: false,592 error: err instanceof Error ? err.message : "failed to open tab",593 };594 }595 }596 case "IS_DURESS_ENABLED": {597 const enabled = await vault.isDuressEnabled();598 return { ok: true, data: enabled };599 }600 case "SETUP_DURESS": {601 const { duressPassword } = msg.payload as { duressPassword: string };602 await vault.setupDuress(duressPassword);603 return { ok: true };604 }605 case "DISABLE_DURESS": {606 await vault.disableDuress();607 return { ok: true };608 }609 case "VERIFY_MAIN_PASSWORD": {610 const { password } = msg.payload as { password: string };611 const ok = await vault.verifyMainPassword(password);612 return { ok: true, data: ok };613 }614 case "CHANGE_PASSWORD": {615 const { currentPassword, newPassword } = msg.payload as {616 currentPassword: string;617 newPassword: string;618 };619 const changed = await vault.changePassword(currentPassword, newPassword);620 return changed621 ? { ok: true }622 : { ok: false, error: "incorrect current password" };623 }624 case "GET_SITE_PASSWORD_POLICY": {625 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });626 if (!tab?.id) return { ok: true, data: null };627 try {628 const response = await chrome.tabs.sendMessage(tab.id, {629 type: "GET_SITE_PASSWORD_POLICY",630 });631 return (response as MessageResponse) ?? { ok: true, data: null };632 } catch {633 return { ok: true, data: null };634 }635 }636 case "GET_SYNC_STATE": {637 const { getPairedDevices, getTrustedNetworks } = await import('./sync-storage');638 const { isOnTailscale } = await import('./sync-network');639 const { isProUser } = await import('./license');640 const { storageGet } = await import('../shared/storage');641 const pairedDevices = await getPairedDevices();642 const trustedNetworks = await getTrustedNetworks();643 const tailscale = await isOnTailscale();644 const pro = await isProUser();645646 let status: SyncStatus = "offline";647 if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {648 if (pairedDevices.length === 0) {649 status = "no_peers";650 } else {651 status = "synced";652 }653 } else {654 status = "not_trusted";655 }656657 const lastSyncRaw = await storageGet("peach_last_sync_at");658 const lastSyncParsed = lastSyncRaw ? Number.parseInt(lastSyncRaw, 10) : NaN;659 const lastSyncAt = Number.isFinite(lastSyncParsed) ? lastSyncParsed : undefined;660661 return { ok: true, data: { pairedDevices, trustedNetworks, status, lastSyncAt, isPro: pro } };662 }663 case "TRIGGER_SYNC": {664 const gate = await requirePro("sync");665 if (!gate.allowed) return { ok: false, error: "pro_required" };666 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };667668 const { getPairedDevices, updatePairedDevice } = await import('./sync-storage');669 const paired = await getPairedDevices();670 if (paired.length === 0) return { ok: false, error: "no_paired_devices" };671672 const lifecycle = await import('./sync-lifecycle');673 const sync = await import('./sync');674 const { storageGet, storageSet } = await import('../shared/storage');675 const { createNativeSyncTransport, runBlobSyncSession } = await import('./sync-coordinator');676677 const discovered = lifecycle.getDiscoveredPeerCache();678 const reachable = paired.filter((device) =>679 discovered.some((peer) => peer.x25519PublicKey === device.x25519PublicKey),680 );681 if (reachable.length === 0) return { ok: false, error: "no_devices_reachable" };682683 684 685 686 const results: Array<{687 deviceName: string;688 status: "completed" | "errored" | "closed";689 error?: string;690 }> = [];691 for (const device of reachable) {692 const peer = discovered.find((p) => p.x25519PublicKey === device.x25519PublicKey)!;693 try {694 const connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);695 const transport = await createNativeSyncTransport(connectionId);696 const result = await runBlobSyncSession("initiator", transport, {697 isPro: async () => true,698 isUnlocked: () => vault.isUnlocked(),699 getVaultBlobs: async () => ({700 vault_a: (await storageGet("peach_vault_a")) ?? "",701 vault_b: (await storageGet("peach_vault_b")) ?? "",702 }),703 });704 results.push({705 deviceName: device.displayName,706 status: result.status,707 error: result.error,708 });709 if (result.status === "completed") {710 await updatePairedDevice(device.deviceId, {711 lastSeenAt: Date.now(),712 lastSyncAt: Date.now(),713 });714 }715 } catch (err) {716 results.push({717 deviceName: device.displayName,718 status: "errored",719 error: err instanceof Error ? err.message : String(err),720 });721 }722 }723724 await storageSet("peach_last_sync_at", String(Date.now()));725 return {726 ok: true,727 data: {728 results,729 syncedCount: results.filter((r) => r.status === "completed").length,730 totalReachable: reachable.length,731 },732 };733 }734 case "START_PAIRING": {735 const gate = await requirePro("sync");736 if (!gate.allowed) return { ok: false, error: "pro_required" };737 return { ok: true };738 }739 case "CANCEL_PAIRING": {740 return { ok: true };741 }742 case "ACCEPT_PAIRING": {743 const gate = await requirePro("sync");744 if (!gate.allowed) return { ok: false, error: "pro_required" };745 return { ok: true };746 }747 case "REMOVE_PAIRED_DEVICE": {748 const { deviceId } = msg.payload as { deviceId: string };749 const { removePairedDevice } = await import('./sync-storage');750 await removePairedDevice(deviceId);751 return { ok: true };752 }753 case "REMOVE_TRUSTED_NETWORK": {754 const { ssid, bssid, isTailscale } = msg.payload as any;755 const { removeTrustedNetwork, getTrustedNetworks } = await import('./sync-storage');756 const networks = await getTrustedNetworks();757 const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 758 (isTailscale ? n.isTailscale : n.ssid === ssid) && 759 (!bssid || n.bssid === bssid)760 );761 if (network) {762 await removeTrustedNetwork(network.addedAt);763 }764 return { ok: true };765 }766 case "MARK_NETWORK_TRUSTED": {767 const { addTrustedNetwork } = await import('./sync-storage');768 const { getCurrentNetwork } = await import('./sync-network');769 const network = await getCurrentNetwork();770 await addTrustedNetwork({771 ssid: network.ssid,772 bssid: network.bssid,773 isTailscale: network.isTailscale,774 addedAt: Date.now(),775 });776 return { ok: true };777 }778 case "GET_INCOMING_PAIRING_REQUEST": {779 const { getIncomingPairingRequest } = await import('./sync-storage');780 const request = await getIncomingPairingRequest();781 return { ok: true, data: request };782 }783 case "RESPOND_INCOMING_PAIRING": {784 const { accept } = msg.payload as { accept: boolean };785 const { getIncomingPairingRequest, clearIncomingPairingRequest, addPairedDevice } =786 await import('./sync-storage');787 const request = await getIncomingPairingRequest();788 if (!request) return { ok: false, error: "no_pending_request" };789 if (Date.now() > request.expiresAt) {790 await clearIncomingPairingRequest();791 return { ok: false, error: "expired" };792 }793794 const sync = await import('./sync');795 const { encodeSyncMessage } = await import('../shared/sync-wire');796797 798 799 await clearIncomingPairingRequest();800801 if (request.connectionId) {802 try {803 if (accept) {804 const ourKey = await sync.getIdentityPublicKey();805 const ourName = await getLocalDeviceName();806 sync.sendSyncData(807 request.connectionId,808 encodeSyncMessage({809 type: "PAIR_ACCEPT",810 public_key_hex: ourKey,811 device_name: ourName,812 }),813 );814 await sync.trustDevice(request.publicKeyHex, request.deviceName);815 await addPairedDevice({816 deviceId: request.publicKeyHex,817 displayName: request.deviceName,818 x25519PublicKey: request.publicKeyHex,819 pairedAt: Date.now(),820 lastSeenAt: Date.now(),821 });822 } else {823 sync.sendSyncData(824 request.connectionId,825 encodeSyncMessage({ type: "PAIR_REJECT", reason: "user_declined" }),826 );827 }828 } catch (err) {829 830 831 void err;832 }833 try { sync.disconnectFromPeer(request.connectionId); } catch { }834 } else if (accept) {835 836 try {837 await sync.trustDevice(request.publicKeyHex, request.deviceName);838 } catch {839 840 }841 }842843 return { ok: true, data: { accepted: accept, deviceName: request.deviceName } };844 }845 case "START_PEER_DISCOVERY": {846 const gate = await requirePro("sync");847 if (!gate.allowed) return { ok: false, error: "pro_required" };848 try {849 await startPeerDiscoveryForPopup();850 return { ok: true };851 } catch (err) {852 return { ok: false, error: err instanceof Error ? err.message : "discovery_failed" };853 }854 }855 case "STOP_PEER_DISCOVERY": {856 try {857 await stopPeerDiscoveryForPopup();858 } catch {859 860 }861 return { ok: true };862 }863 case "INITIATE_PAIR_WITH_PEER": {864 const gate = await requirePro("sync");865 if (!gate.allowed) return { ok: false, error: "pro_required" };866 const { peer } = msg.payload as {867 peer: { deviceId: string; deviceName: string; host: string; port: number; x25519PublicKey: string };868 };869870 const sync = await import('./sync');871 const { encodeSyncMessage } = await import('../shared/sync-wire');872 const { waitForPairingResponse } = await import('./sync-coordinator');873 const { addPairedDevice } = await import('./sync-storage');874875 let connectionId: string | undefined;876 try {877 878 879 880 881 882 connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);883884 const ourPublicKeyHex = await sync.getIdentityPublicKey();885 const deviceName = await getLocalDeviceName();886887 sync.sendSyncData(888 connectionId,889 encodeSyncMessage({890 type: "PAIR_REQUEST",891 public_key_hex: ourPublicKeyHex,892 device_name: deviceName,893 }),894 );895896 const response = await waitForPairingResponse(connectionId, 30_000);897898 if (response.type === "PAIR_ACCEPT") {899 await sync.trustDevice(response.public_key_hex, response.device_name);900 await addPairedDevice({901 deviceId: peer.deviceId,902 displayName: response.device_name || peer.deviceName,903 x25519PublicKey: response.public_key_hex,904 pairedAt: Date.now(),905 lastSeenAt: Date.now(),906 });907 sync.disconnectFromPeer(connectionId);908 return { ok: true, data: { connectionId, deviceName: response.device_name } };909 }910911 if (response.type === "PAIR_REJECT") {912 sync.disconnectFromPeer(connectionId);913 return { ok: false, error: "pair_rejected", data: { reason: response.reason } };914 }915916 sync.disconnectFromPeer(connectionId);917 return { ok: false, error: "unexpected_response" };918 } catch (err) {919 if (connectionId) {920 try { sync.disconnectFromPeer(connectionId); } catch { }921 }922 return { ok: false, error: err instanceof Error ? err.message : "pair_failed" };923 }924 }925 case "DEBUG_INJECT_INCOMING_PAIRING": {926 927 928 929 930 const { deviceName, publicKeyHex } = (msg.payload ?? {}) as {931 deviceName?: string;932 publicKeyHex?: string;933 };934 if (!deviceName || !publicKeyHex) {935 return { ok: false, error: "missing_payload" };936 }937 const { setIncomingPairingRequest } = await import('./sync-storage');938 const now = Date.now();939 await setIncomingPairingRequest({940 deviceName,941 publicKeyHex,942 receivedAt: now,943 expiresAt: now + 60_000,944 });945 return { ok: true };946 }947 case "RESET_SYNC_IDENTITY": {948 const gate = await requirePro("sync");949 if (!gate.allowed) return { ok: false, error: "pro_required" };950 const { getPairedDevices, removePairedDevice } = await import('./sync-storage');951 const devices = await getPairedDevices();952 for (const device of devices) {953 await removePairedDevice(device.deviceId);954 }955 956 957 958 959 let nativePublicKey: string | null = null;960 try {961 const { rotateNativeIdentity, disconnectSyncHost } = await import('./sync');962 nativePublicKey = await rotateNativeIdentity();963 disconnectSyncHost();964 } catch {965 nativePublicKey = null;966 }967 return { ok: true, data: { removedDevices: devices.length, nativePublicKey } };968 }969 case "RECORD_MANUAL_SUMMON": {970 const { domain } = msg.payload as { domain: string };971 const settings = vault.getSettings();972 const existing = settings.manualSummonDomains?.[domain];973 const now = Date.now();974 await vault.updateSettings({975 manualSummonDomains: {976 ...settings.manualSummonDomains,977 [domain]: {978 firstSummonedAt: existing?.firstSummonedAt ?? now,979 summonCount: (existing?.summonCount ?? 0) + 1,980 lastSummonedAt: now,981 },982 },983 });984 return { ok: true };985 }986 case "DECLINE_CARD_SAVE": {987 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };988 await vault.declineCardSaveOffer(last4, expMonth, expYear);989 return { ok: true };990 }991 case "IS_CARD_DECLINED": {992 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };993 const declined = await vault.isCardDeclined(last4, expMonth, expYear);994 return { ok: true, data: declined };995 }996 case "INITIATE_FIREFOX_PASSKEY_CREATE": {997 const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {998 rpId?: string;999 rpName?: string;1000 userName?: string;1001 userDisplayName?: string;1002 };1003 if (!rpId || !userName) return { ok: false, error: "rpId and userName required" };1004 return await initiateFirefoxPasskeyCreate({1005 rpId,1006 rpName: rpName ?? rpId,1007 userName,1008 userDisplayName,1009 });1010 }10111012 case "COMPLETE_FIREFOX_PASSKEY_CREATE": {1013 const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;1014 if (!payload) return { ok: false, error: "missing payload" };1015 return await completeFirefoxPasskeyCreate(payload);1016 }10171018 case "PASSKEY_OFFER_DECLINE": {1019 const { domain } = (msg.payload ?? {}) as { domain?: string };1020 if (!domain) return { ok: false, error: "domain required" };1021 await recordPasskeyOfferDecline(domain);1022 return { ok: true };1023 }10241025 case "GET_PASSKEY_OFFER_DECLINED": {1026 const { domain } = (msg.payload ?? {}) as { domain?: string };1027 if (!domain) return { ok: false, error: "domain required" };1028 return { ok: true, data: await isPasskeyOfferDeclined(domain) };1029 }10301031 case "PASSKEY_HAS_CREDENTIALS": {1032 const payload = msg.payload as {1033 rpId?: string;1034 allowCredentials?: Array<{ id: string; type?: string }>;1035 } | undefined;1036 if (!payload?.rpId) return { ok: false, error: "rpId required" };1037 return await handlePasskeyHasCredentials({1038 rpId: payload.rpId,1039 allowCredentials: payload.allowCredentials ?? [],1040 });1041 }10421043 case "PASSKEY_CREATE": {1044 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;1045 if (!payload?.rpId || !payload?.userName) {1046 return { ok: false, error: "rpId and userName required" };1047 }1048 return await handleFirefoxPasskeyCreate(payload);1049 }10501051 case "PASSKEY_GET": {1052 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;1053 if (!payload?.rpId) return { ok: false, error: "rpId required" };1054 return await handleFirefoxPasskeyGet(payload);1055 }10561057 case "GET_PASSKEY_FOR_DOMAIN": {1058 const { domain } = (msg.payload ?? {}) as { domain?: string };1059 if (!domain) return { ok: false, error: "domain required" };1060 if (!vault.isUnlocked()) return { ok: true, data: null };1061 const match = vault1062 .getEntries()1063 .find((entry) => entry.passkey?.rpId === domain);1064 if (!match || !match.passkey) return { ok: true, data: null };1065 1066 1067 return {1068 ok: true,1069 data: {1070 rpId: match.passkey.rpId,1071 userName: match.passkey.userName,1072 browser: match.passkey.browser,1073 },1074 };1075 }10761077 case "DESTROY_VAULT": {1078 1079 1080 1081 1082 1083 vault.lock();1084 await storageClear();1085 1086 1087 1088 1089 1090 await vault.clearVaultHistory();1091 if (!Capacitor.isNativePlatform() && chrome.storage.session) {1092 await chrome.storage.session.clear();1093 }1094 return { ok: true };1095 }10961097 case "SUBMIT_FEEDBACK": {1098 const { category, message, version, browser } = msg.payload as {1099 category: string;1100 message: string;1101 version: string;1102 browser: string;1103 };1104 if (!message.trim()) return { ok: false, error: "message is empty" };1105 if (!FEEDBACK_ENDPOINT) return { ok: true };1106 if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {1107 return { ok: false, error: "invalid feedback endpoint" };1108 }1109 try {1110 const res = await fetch(FEEDBACK_ENDPOINT, {1111 method: "POST",1112 headers: { "Content-Type": "application/json" },1113 body: JSON.stringify({ category, message, version, browser, source: "peach-extension" }),1114 });1115 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };1116 } catch (err) {1117 return { ok: false, error: err instanceof Error ? err.message : "network error" };1118 }1119 }1120 default:1121 return { ok: false, error: "unknown message type" };1122 }1123 } catch (err) {1124 return {1125 ok: false,1126 error: err instanceof Error ? err.message : "operation failed",1127 };1128 }1129}113011311132function broadcastToContentScripts(message: { type: string }): void {1133 if (Capacitor.isNativePlatform() || !chrome.tabs?.query) return;1134 chrome.tabs.query({}, (tabs) => {1135 for (const tab of tabs) {1136 if (tab.id !== undefined) {1137 chrome.tabs.sendMessage(tab.id, message).catch(() => {1138 1139 });1140 }1141 }1142 });1143}11441145function shouldRefreshAutoLock(type: MessageType): boolean {1146 return (1147 type !== "HAS_VAULT" &&1148 type !== "IS_UNLOCKED" &&1149 type !== "GET_VAULT_STATUS" &&1150 type !== "LOCK" &&1151 type !== "UPDATE_SETTINGS" &&1152 type !== "GET_PENDING_PASSKEY" &&1153 type !== "GET_PASSKEY_PROVIDER_STATE" &&1154 type !== "DECLINE_CARD_SAVE" &&1155 type !== "IS_CARD_DECLINED" &&1156 type !== "SUBMIT_FEEDBACK"1157 );1158}11591160function isAllowedCanaryWebhookUrl(value: string): boolean {1161 try {1162 const url = new URL(value);1163 if (url.protocol !== "https:") return false;11641165 const hostname = url.hostname.toLowerCase();1166 if (1167 hostname === "localhost" ||1168 hostname.endsWith(".localhost") ||1169 isPrivateIpv4(hostname) ||1170 isPrivateIpv6(hostname)1171 ) {1172 return false;1173 }11741175 return true;1176 } catch {1177 return false;1178 }1179}11801181function isPrivateIpv4(hostname: string): boolean {1182 if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {1183 return true;1184 }11851186 const octets = hostname.split(".").map((part) => Number(part));1187 if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {1188 return false;1189 }11901191 return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;1192}11931194function isPrivateIpv6(hostname: string): boolean {1195 const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();1196 return (1197 normalized === "::1" ||1198 normalized.startsWith("fc") ||1199 normalized.startsWith("fd") ||1200 normalized.startsWith("fe80:")1201 );1202}