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 pairedDevices = await getPairedDevices();641 const trustedNetworks = await getTrustedNetworks();642 const tailscale = await isOnTailscale();643 const pro = await isProUser();644 645 let status: SyncStatus = "offline";646 if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {647 if (pairedDevices.length === 0) {648 status = "no_peers";649 } else {650 status = "synced";651 }652 } else {653 status = "not_trusted";654 }655 656 return { ok: true, data: { pairedDevices, trustedNetworks, status, isPro: pro } };657 }658 case "TRIGGER_SYNC": {659 const gate = await requirePro("sync");660 if (!gate.allowed) return { ok: false, error: "pro_required" };661 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };662663 const { getPairedDevices, updatePairedDevice } = await import('./sync-storage');664 const paired = await getPairedDevices();665 if (paired.length === 0) return { ok: false, error: "no_paired_devices" };666667 const lifecycle = await import('./sync-lifecycle');668 const sync = await import('./sync');669 const { storageGet, storageSet } = await import('../shared/storage');670 const { createNativeSyncTransport, runBlobSyncSession } = await import('./sync-coordinator');671672 const discovered = lifecycle.getDiscoveredPeerCache();673 const reachable = paired.filter((device) =>674 discovered.some((peer) => peer.x25519PublicKey === device.x25519PublicKey),675 );676 if (reachable.length === 0) return { ok: false, error: "no_devices_reachable" };677678 679 680 681 const results: Array<{682 deviceName: string;683 status: "completed" | "errored" | "closed";684 error?: string;685 }> = [];686 for (const device of reachable) {687 const peer = discovered.find((p) => p.x25519PublicKey === device.x25519PublicKey)!;688 try {689 const connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);690 const transport = await createNativeSyncTransport(connectionId);691 const result = await runBlobSyncSession("initiator", transport, {692 isPro: async () => true,693 isUnlocked: () => vault.isUnlocked(),694 getVaultBlobs: async () => ({695 vault_a: (await storageGet("peach_vault_a")) ?? "",696 vault_b: (await storageGet("peach_vault_b")) ?? "",697 }),698 });699 results.push({700 deviceName: device.displayName,701 status: result.status,702 error: result.error,703 });704 if (result.status === "completed") {705 await updatePairedDevice(device.deviceId, {706 lastSeenAt: Date.now(),707 lastSyncAt: Date.now(),708 });709 }710 } catch (err) {711 results.push({712 deviceName: device.displayName,713 status: "errored",714 error: err instanceof Error ? err.message : String(err),715 });716 }717 }718719 await storageSet("peach_last_sync_at", String(Date.now()));720 return {721 ok: true,722 data: {723 results,724 syncedCount: results.filter((r) => r.status === "completed").length,725 totalReachable: reachable.length,726 },727 };728 }729 case "START_PAIRING": {730 const gate = await requirePro("sync");731 if (!gate.allowed) return { ok: false, error: "pro_required" };732 return { ok: true };733 }734 case "CANCEL_PAIRING": {735 return { ok: true };736 }737 case "ACCEPT_PAIRING": {738 const gate = await requirePro("sync");739 if (!gate.allowed) return { ok: false, error: "pro_required" };740 return { ok: true };741 }742 case "REMOVE_PAIRED_DEVICE": {743 const { deviceId } = msg.payload as { deviceId: string };744 const { removePairedDevice } = await import('./sync-storage');745 await removePairedDevice(deviceId);746 return { ok: true };747 }748 case "REMOVE_TRUSTED_NETWORK": {749 const { ssid, bssid, isTailscale } = msg.payload as any;750 const { removeTrustedNetwork, getTrustedNetworks } = await import('./sync-storage');751 const networks = await getTrustedNetworks();752 const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 753 (isTailscale ? n.isTailscale : n.ssid === ssid) && 754 (!bssid || n.bssid === bssid)755 );756 if (network) {757 await removeTrustedNetwork(network.addedAt);758 }759 return { ok: true };760 }761 case "MARK_NETWORK_TRUSTED": {762 const { addTrustedNetwork } = await import('./sync-storage');763 const { getCurrentNetwork } = await import('./sync-network');764 const network = await getCurrentNetwork();765 await addTrustedNetwork({766 ssid: network.ssid,767 bssid: network.bssid,768 isTailscale: network.isTailscale,769 addedAt: Date.now(),770 });771 return { ok: true };772 }773 case "GET_INCOMING_PAIRING_REQUEST": {774 const { getIncomingPairingRequest } = await import('./sync-storage');775 const request = await getIncomingPairingRequest();776 return { ok: true, data: request };777 }778 case "RESPOND_INCOMING_PAIRING": {779 const { accept } = msg.payload as { accept: boolean };780 const { getIncomingPairingRequest, clearIncomingPairingRequest, addPairedDevice } =781 await import('./sync-storage');782 const request = await getIncomingPairingRequest();783 if (!request) return { ok: false, error: "no_pending_request" };784 if (Date.now() > request.expiresAt) {785 await clearIncomingPairingRequest();786 return { ok: false, error: "expired" };787 }788789 const sync = await import('./sync');790 const { encodeSyncMessage } = await import('../shared/sync-wire');791792 793 794 await clearIncomingPairingRequest();795796 if (request.connectionId) {797 try {798 if (accept) {799 const ourKey = await sync.getIdentityPublicKey();800 const ourName = await getLocalDeviceName();801 sync.sendSyncData(802 request.connectionId,803 encodeSyncMessage({804 type: "PAIR_ACCEPT",805 public_key_hex: ourKey,806 device_name: ourName,807 }),808 );809 await sync.trustDevice(request.publicKeyHex, request.deviceName);810 await addPairedDevice({811 deviceId: request.publicKeyHex,812 displayName: request.deviceName,813 x25519PublicKey: request.publicKeyHex,814 pairedAt: Date.now(),815 lastSeenAt: Date.now(),816 });817 } else {818 sync.sendSyncData(819 request.connectionId,820 encodeSyncMessage({ type: "PAIR_REJECT", reason: "user_declined" }),821 );822 }823 } catch (err) {824 825 826 void err;827 }828 try { sync.disconnectFromPeer(request.connectionId); } catch { }829 } else if (accept) {830 831 try {832 await sync.trustDevice(request.publicKeyHex, request.deviceName);833 } catch {834 835 }836 }837838 return { ok: true, data: { accepted: accept, deviceName: request.deviceName } };839 }840 case "START_PEER_DISCOVERY": {841 const gate = await requirePro("sync");842 if (!gate.allowed) return { ok: false, error: "pro_required" };843 try {844 await startPeerDiscoveryForPopup();845 return { ok: true };846 } catch (err) {847 return { ok: false, error: err instanceof Error ? err.message : "discovery_failed" };848 }849 }850 case "STOP_PEER_DISCOVERY": {851 try {852 await stopPeerDiscoveryForPopup();853 } catch {854 855 }856 return { ok: true };857 }858 case "INITIATE_PAIR_WITH_PEER": {859 const gate = await requirePro("sync");860 if (!gate.allowed) return { ok: false, error: "pro_required" };861 const { peer } = msg.payload as {862 peer: { deviceId: string; deviceName: string; host: string; port: number; x25519PublicKey: string };863 };864865 const sync = await import('./sync');866 const { encodeSyncMessage } = await import('../shared/sync-wire');867 const { waitForPairingResponse } = await import('./sync-coordinator');868 const { addPairedDevice } = await import('./sync-storage');869870 let connectionId: string | undefined;871 try {872 873 874 875 876 877 connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);878879 const ourPublicKeyHex = await sync.getIdentityPublicKey();880 const deviceName = await getLocalDeviceName();881882 sync.sendSyncData(883 connectionId,884 encodeSyncMessage({885 type: "PAIR_REQUEST",886 public_key_hex: ourPublicKeyHex,887 device_name: deviceName,888 }),889 );890891 const response = await waitForPairingResponse(connectionId, 30_000);892893 if (response.type === "PAIR_ACCEPT") {894 await sync.trustDevice(response.public_key_hex, response.device_name);895 await addPairedDevice({896 deviceId: peer.deviceId,897 displayName: response.device_name || peer.deviceName,898 x25519PublicKey: response.public_key_hex,899 pairedAt: Date.now(),900 lastSeenAt: Date.now(),901 });902 sync.disconnectFromPeer(connectionId);903 return { ok: true, data: { connectionId, deviceName: response.device_name } };904 }905906 if (response.type === "PAIR_REJECT") {907 sync.disconnectFromPeer(connectionId);908 return { ok: false, error: "pair_rejected", data: { reason: response.reason } };909 }910911 sync.disconnectFromPeer(connectionId);912 return { ok: false, error: "unexpected_response" };913 } catch (err) {914 if (connectionId) {915 try { sync.disconnectFromPeer(connectionId); } catch { }916 }917 return { ok: false, error: err instanceof Error ? err.message : "pair_failed" };918 }919 }920 case "DEBUG_INJECT_INCOMING_PAIRING": {921 922 923 924 925 const { deviceName, publicKeyHex } = (msg.payload ?? {}) as {926 deviceName?: string;927 publicKeyHex?: string;928 };929 if (!deviceName || !publicKeyHex) {930 return { ok: false, error: "missing_payload" };931 }932 const { setIncomingPairingRequest } = await import('./sync-storage');933 const now = Date.now();934 await setIncomingPairingRequest({935 deviceName,936 publicKeyHex,937 receivedAt: now,938 expiresAt: now + 60_000,939 });940 return { ok: true };941 }942 case "RESET_SYNC_IDENTITY": {943 const gate = await requirePro("sync");944 if (!gate.allowed) return { ok: false, error: "pro_required" };945 const { getPairedDevices, removePairedDevice } = await import('./sync-storage');946 const devices = await getPairedDevices();947 for (const device of devices) {948 await removePairedDevice(device.deviceId);949 }950 951 952 953 954 let nativePublicKey: string | null = null;955 try {956 const { rotateNativeIdentity, disconnectSyncHost } = await import('./sync');957 nativePublicKey = await rotateNativeIdentity();958 disconnectSyncHost();959 } catch {960 nativePublicKey = null;961 }962 return { ok: true, data: { removedDevices: devices.length, nativePublicKey } };963 }964 case "RECORD_MANUAL_SUMMON": {965 const { domain } = msg.payload as { domain: string };966 const settings = vault.getSettings();967 const existing = settings.manualSummonDomains?.[domain];968 const now = Date.now();969 await vault.updateSettings({970 manualSummonDomains: {971 ...settings.manualSummonDomains,972 [domain]: {973 firstSummonedAt: existing?.firstSummonedAt ?? now,974 summonCount: (existing?.summonCount ?? 0) + 1,975 lastSummonedAt: now,976 },977 },978 });979 return { ok: true };980 }981 case "DECLINE_CARD_SAVE": {982 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };983 await vault.declineCardSaveOffer(last4, expMonth, expYear);984 return { ok: true };985 }986 case "IS_CARD_DECLINED": {987 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };988 const declined = await vault.isCardDeclined(last4, expMonth, expYear);989 return { ok: true, data: declined };990 }991 case "INITIATE_FIREFOX_PASSKEY_CREATE": {992 const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {993 rpId?: string;994 rpName?: string;995 userName?: string;996 userDisplayName?: string;997 };998 if (!rpId || !userName) return { ok: false, error: "rpId and userName required" };999 return await initiateFirefoxPasskeyCreate({1000 rpId,1001 rpName: rpName ?? rpId,1002 userName,1003 userDisplayName,1004 });1005 }10061007 case "COMPLETE_FIREFOX_PASSKEY_CREATE": {1008 const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;1009 if (!payload) return { ok: false, error: "missing payload" };1010 return await completeFirefoxPasskeyCreate(payload);1011 }10121013 case "PASSKEY_OFFER_DECLINE": {1014 const { domain } = (msg.payload ?? {}) as { domain?: string };1015 if (!domain) return { ok: false, error: "domain required" };1016 await recordPasskeyOfferDecline(domain);1017 return { ok: true };1018 }10191020 case "GET_PASSKEY_OFFER_DECLINED": {1021 const { domain } = (msg.payload ?? {}) as { domain?: string };1022 if (!domain) return { ok: false, error: "domain required" };1023 return { ok: true, data: await isPasskeyOfferDeclined(domain) };1024 }10251026 case "PASSKEY_HAS_CREDENTIALS": {1027 const payload = msg.payload as {1028 rpId?: string;1029 allowCredentials?: Array<{ id: string; type?: string }>;1030 } | undefined;1031 if (!payload?.rpId) return { ok: false, error: "rpId required" };1032 return await handlePasskeyHasCredentials({1033 rpId: payload.rpId,1034 allowCredentials: payload.allowCredentials ?? [],1035 });1036 }10371038 case "PASSKEY_CREATE": {1039 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;1040 if (!payload?.rpId || !payload?.userName) {1041 return { ok: false, error: "rpId and userName required" };1042 }1043 return await handleFirefoxPasskeyCreate(payload);1044 }10451046 case "PASSKEY_GET": {1047 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;1048 if (!payload?.rpId) return { ok: false, error: "rpId required" };1049 return await handleFirefoxPasskeyGet(payload);1050 }10511052 case "GET_PASSKEY_FOR_DOMAIN": {1053 const { domain } = (msg.payload ?? {}) as { domain?: string };1054 if (!domain) return { ok: false, error: "domain required" };1055 if (!vault.isUnlocked()) return { ok: true, data: null };1056 const match = vault1057 .getEntries()1058 .find((entry) => entry.passkey?.rpId === domain);1059 if (!match || !match.passkey) return { ok: true, data: null };1060 1061 1062 return {1063 ok: true,1064 data: {1065 rpId: match.passkey.rpId,1066 userName: match.passkey.userName,1067 browser: match.passkey.browser,1068 },1069 };1070 }10711072 case "DESTROY_VAULT": {1073 1074 1075 1076 1077 1078 vault.lock();1079 await storageClear();1080 1081 1082 1083 1084 1085 await vault.clearVaultHistory();1086 if (!Capacitor.isNativePlatform() && chrome.storage.session) {1087 await chrome.storage.session.clear();1088 }1089 return { ok: true };1090 }10911092 case "SUBMIT_FEEDBACK": {1093 const { category, message, version, browser } = msg.payload as {1094 category: string;1095 message: string;1096 version: string;1097 browser: string;1098 };1099 if (!message.trim()) return { ok: false, error: "message is empty" };1100 if (!FEEDBACK_ENDPOINT) return { ok: true };1101 if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {1102 return { ok: false, error: "invalid feedback endpoint" };1103 }1104 try {1105 const res = await fetch(FEEDBACK_ENDPOINT, {1106 method: "POST",1107 headers: { "Content-Type": "application/json" },1108 body: JSON.stringify({ category, message, version, browser, source: "peach-extension" }),1109 });1110 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };1111 } catch (err) {1112 return { ok: false, error: err instanceof Error ? err.message : "network error" };1113 }1114 }1115 default:1116 return { ok: false, error: "unknown message type" };1117 }1118 } catch (err) {1119 return {1120 ok: false,1121 error: err instanceof Error ? err.message : "operation failed",1122 };1123 }1124}112511261127function broadcastToContentScripts(message: { type: string }): void {1128 if (Capacitor.isNativePlatform() || !chrome.tabs?.query) return;1129 chrome.tabs.query({}, (tabs) => {1130 for (const tab of tabs) {1131 if (tab.id !== undefined) {1132 chrome.tabs.sendMessage(tab.id, message).catch(() => {1133 1134 });1135 }1136 }1137 });1138}11391140function shouldRefreshAutoLock(type: MessageType): boolean {1141 return (1142 type !== "HAS_VAULT" &&1143 type !== "IS_UNLOCKED" &&1144 type !== "GET_VAULT_STATUS" &&1145 type !== "LOCK" &&1146 type !== "UPDATE_SETTINGS" &&1147 type !== "GET_PENDING_PASSKEY" &&1148 type !== "GET_PASSKEY_PROVIDER_STATE" &&1149 type !== "DECLINE_CARD_SAVE" &&1150 type !== "IS_CARD_DECLINED" &&1151 type !== "SUBMIT_FEEDBACK"1152 );1153}11541155function isAllowedCanaryWebhookUrl(value: string): boolean {1156 try {1157 const url = new URL(value);1158 if (url.protocol !== "https:") return false;11591160 const hostname = url.hostname.toLowerCase();1161 if (1162 hostname === "localhost" ||1163 hostname.endsWith(".localhost") ||1164 isPrivateIpv4(hostname) ||1165 isPrivateIpv6(hostname)1166 ) {1167 return false;1168 }11691170 return true;1171 } catch {1172 return false;1173 }1174}11751176function isPrivateIpv4(hostname: string): boolean {1177 if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {1178 return true;1179 }11801181 const octets = hostname.split(".").map((part) => Number(part));1182 if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {1183 return false;1184 }11851186 return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;1187}11881189function isPrivateIpv6(hostname: string): boolean {1190 const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();1191 return (1192 normalized === "::1" ||1193 normalized.startsWith("fc") ||1194 normalized.startsWith("fd") ||1195 normalized.startsWith("fe80:")1196 );1197}