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}104105106107108const PAIRING_TOKEN_TTL_MS = 5 * 60 * 1000;109110111112113114let activeAndroidPairingHandle: { stop: () => Promise<void> } | null = null;115116function generateSixDigitToken(): string {117 118 119 120 121 const buf = new Uint32Array(1);122 crypto.getRandomValues(buf);123 const value = buf[0] % 1_000_000;124 return value.toString().padStart(6, "0");125}126127128129130131132133134135136137let peerDiscoverySubscriptions: Array<() => void> = [];138139async function startPeerDiscoveryForPopup(): Promise<void> {140 await stopPeerDiscoveryForPopup();141 const sync = await import('./sync');142 const { getPairedDevices } = await import('./sync-storage');143 sync.connectSyncHost();144145 const unsubDiscovered = sync.onPeerDiscovered((peer) => {146 void (async () => {147 const paired = await getPairedDevices();148 const alreadyPaired = paired.some((d) => d.deviceId === peer.deviceId);149 if (Capacitor.isNativePlatform()) return;150 chrome.runtime151 .sendMessage({152 type: "SYNC_PEER_DISCOVERED",153 peer: {154 deviceId: peer.deviceId,155 deviceName: peer.name,156 host: peer.host,157 port: peer.port,158 wsPort: peer.wsPort,159 x25519PublicKey: peer.x25519PublicKey,160 alreadyPaired,161 },162 })163 .catch(() => {});164 })();165 });166 const unsubLost = sync.onPeerLost((deviceId) => {167 if (Capacitor.isNativePlatform()) return;168 chrome.runtime169 .sendMessage({ type: "SYNC_PEER_LOST", deviceId })170 .catch(() => {});171 });172 peerDiscoverySubscriptions = [unsubDiscovered, unsubLost];173 sync.discoverPeers();174}175176async function stopPeerDiscoveryForPopup(): Promise<void> {177 for (const unsub of peerDiscoverySubscriptions) unsub();178 peerDiscoverySubscriptions = [];179 try {180 const sync = await import('./sync');181 if (sync.isHostConnected()) {182 await sync.stopDiscovery();183 }184 } catch {185 186 }187}188189const PRIVATE_IPV4_RANGES = [190 { prefix: "10." },191 { prefix: "127." },192 { prefix: "169.254." },193 { prefix: "192.168." },194];195196197198199200const FEEDBACK_ENDPOINT = "";201202export function setupMessageHandler(): void {203 chrome.runtime.onMessage.addListener(204 (msg: unknown, sender, sendResponse) => {205 if (!isMessage(msg)) {206 sendResponse({ ok: false, error: "invalid message" });207 return true;208 }209 handleMessage(msg, sender)210 .then(sendResponse)211 .catch((err) =>212 sendResponse({213 ok: false,214 error: err instanceof Error ? err.message : String(err),215 })216 );217 return true;218 }219 );220}221222function isMessage(m: unknown): m is Message {223 return typeof m === "object" && m !== null && "type" in m;224}225226export async function handleMessage(227 msg: Message,228 sender: Partial<chrome.runtime.MessageSender> = {}229): Promise<MessageResponse> {230 try {231 232 233 234 235 if (236 !Capacitor.isNativePlatform() &&237 sender.id !== undefined &&238 sender.id !== chrome.runtime.id239 ) {240 return { ok: false, error: "invalid sender" };241 }242243 if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {244 touchAutoLock();245 }246247 switch (msg.type) {248 case "CREATE_VAULT": {249 const { password } = msg.payload as { password: string };250 await vault.createVault(password);251 await setupAutoLock();252 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });253 void syncLifecycle.onVaultUnlocked();254 return { ok: true };255 }256 case "UNLOCK": {257 const { password } = msg.payload as { password: string };258 const ok = await vault.unlock(password);259 if (ok) {260 await setupAutoLock();261 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });262 void syncLifecycle.onVaultUnlocked();263 }264 return { ok };265 }266 case "LOCK":267 vault.lock();268 void syncLifecycle.onVaultLocked();269 return { ok: true };270 case "IS_UNLOCKED":271 return { ok: true, data: vault.isUnlocked() };272 case "GET_VAULT_STATUS":273 return { ok: true, unlocked: vault.isUnlocked() };274 case "HAS_VAULT":275 return { ok: true, data: await vault.hasVault() };276 case "GET_ENTRIES":277 return { ok: true, data: vault.getEntries() };278 case "ADD_ENTRY": {279 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };280 const entry = await vault.addEntry(msg.payload as any);281 return { ok: true, data: entry };282 }283 case "BATCH_ADD_ENTRIES": {284 const { entries } = msg.payload as { entries: any[] };285 const added = await vault.batchAddEntries(entries);286 return { ok: true, data: added };287 }288 case "RESTORE_ENTRIES": {289 const { entries, mode, settings } = msg.payload as {290 entries: any[];291 mode: "replace" | "merge";292 settings?: vault.VaultSettings;293 };294 const summary = await vault.restoreEntries(entries, mode);295 if (settings && typeof settings === "object") {296 await vault.updateSettings(settings);297 }298 return { ok: true, data: summary };299 }300 case "CLEAR_VAULT_ENTRIES": {301 await vault.clearVaultEntries("REPLACE_ALL");302 return { ok: true };303 }304 case "GET_VAULT_HISTORY": {305 const snapshots = await vault.getVaultHistory();306 return { ok: true, data: snapshots };307 }308 case "RESTORE_VAULT_SNAPSHOT": {309 const { snapshotId } = msg.payload as { snapshotId: string };310 const result = await vault.restoreVaultSnapshot(snapshotId);311 if (!result.ok) return { ok: false, error: result.error };312 broadcastToContentScripts({ type: "VAULT_LOCKED" });313 return { ok: true };314 }315 case "COUNT_DUPLICATES": {316 return { ok: true, data: vault.countDuplicates() };317 }318 case "DEDUPLICATE_ENTRIES": {319 const removed = await vault.deduplicateEntries();320 return { ok: true, data: removed };321 }322 case "UPDATE_ENTRY": {323 const { id, patch } = msg.payload as any;324 await vault.updateEntry(id, patch);325 return { ok: true };326 }327 case "ADD_ADDITIONAL_URL": {328 const { id, url } = msg.payload as { id: string; url: string };329 const trimmed = typeof url === "string" ? url.trim() : "";330 if (!trimmed) return { ok: false, error: "url required" };331 const existing = vault.getEntryById(id);332 if (!existing) return { ok: false, error: "entry not found" };333 if (existing.type !== "login") return { ok: false, error: "login entries only" };334 const merged = [...(existing.additionalUrls ?? []), trimmed];335 await vault.updateEntry(id, { additionalUrls: merged });336 return { ok: true };337 }338 case "DELETE_ENTRY": {339 const { id } = msg.payload as { id: string };340 await vault.deleteEntry(id);341 return { ok: true };342 }343 case "GET_DELETED_ENTRIES":344 return { ok: true, data: vault.getDeletedEntries() };345 case "RESTORE_ENTRY": {346 const { id } = msg.payload as { id: string };347 await vault.restoreEntry(id);348 return { ok: true };349 }350 case "PURGE_ENTRY": {351 const { id } = msg.payload as { id: string };352 await vault.purgeEntry(id);353 return { ok: true };354 }355 case "PURGE_EXPIRED_TRASH": {356 const { retentionMs } = msg.payload as { retentionMs: number };357 const purged = await vault.purgeExpiredTrash(retentionMs);358 return { ok: true, data: purged };359 }360 case "AUTOFILL_QUERY": {361 if (!vault.isUnlocked()) {362 return { ok: false, error: "VAULT_LOCKED" };363 }364 const { domain, includeAll, kind } = msg.payload as {365 domain: string;366 includeAll?: boolean;367 kind?: "login" | "card";368 };369 370 371 372 373 if (kind === "card") {374 const cards = vault.getEntries().filter((e) => e.type === "card");375 return {376 ok: true,377 data: cards.map((e) => ({378 id: e.id,379 title: e.cardNickname || e.title,380 cardholderName: e.cardholderName,381 last4: e.last4,382 expMonth: e.expMonth,383 expYear: e.expYear,384 network: e.network,385 })),386 };387 }388 389 390 391 392 393 const matches = (394 includeAll395 ? vault396 .getEntries()397 .filter((entry) => entry.type !== "note" && entry.type !== "card")398 : vault.queryDomain(domain)399 ).filter((entry) => !isPasskeyOnly(entry));400 return {401 ok: true,402 data: matches.map((e) =>403 includeAll404 ? {405 id: e.id,406 title: e.title,407 hasPasskey: e.passkey !== undefined,408 }409 : {410 id: e.id,411 title: e.title,412 username: e.username,413 url: e.url,414 hasTotp: Boolean(e.totp),415 hasPasskey: e.passkey !== undefined,416 }417 ),418 };419 }420 case "GET_ENTRY_FOR_FILL": {421 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };422 const { id } = msg.payload as { id: string };423 const entry = vault.getEntryById(id);424 if (!entry) return { ok: false, error: "entry not found" };425 426 427 428 if (isPasskeyOnly(entry)) return { ok: false, error: "passkey not fillable" };429 430 431 432 const { passkey: _strippedPasskey, ...safeEntry } = entry;433 return { ok: true, data: safeEntry };434 }435 case "COPY": {436 const { text, label, alreadyWritten } = msg.payload as {437 text: string;438 label: string;439 alreadyWritten?: boolean;440 };441 await copyToClipboard(text, label, Boolean(alreadyWritten));442 return { ok: true };443 }444 case "CHECK_BREACH": {445 const { password } = msg.payload as { password: string };446 const count = await vault.checkBreach(password);447 return { ok: true, data: count };448 }449 case "CHECK_ALL_BREACHES": {450 const allEntries = vault.getEntries();451 const results: Record<string, number> = {};452 for (const entry of allEntries) {453 if (entry.type === "note") continue;454 if (!entry.password) continue;455 results[entry.id] = await vault.checkBreach(entry.password);456 457 await new Promise((r) => setTimeout(r, 200));458 }459 460 for (const [id, count] of Object.entries(results)) {461 const entry = vault.getEntryById(id);462 if (entry && (entry.breach ?? 0) !== count) {463 await vault.updateEntry(id, { breach: count > 0 ? count : undefined });464 }465 }466 return { ok: true, data: results };467 }468 case "GET_SETTINGS":469 return { ok: true, data: await vault.ensureSettingsLoaded() };470 case "UPDATE_SETTINGS": {471 const patch = msg.payload as Record<string, unknown>;472 await vault.updateSettings(patch as any);473 if (typeof patch.autoLockMinutes === "number") {474 await setupAutoLock();475 if (vault.isUnlocked()) touchAutoLock();476 }477 return { ok: true };478 }479 case "GET_PENDING_PASSKEY":480 return { ok: true, data: await getPendingPasskeyRequest() };481 case "APPROVE_PASSKEY": {482 const { requestId } = msg.payload as { requestId: number };483 await resolvePendingPasskeyRequest(requestId, true);484 return { ok: true };485 }486 case "DENY_PASSKEY": {487 const { requestId } = msg.payload as { requestId: number };488 await resolvePendingPasskeyRequest(requestId, false);489 return { ok: true };490 }491 case "GET_PASSKEYS":492 return {493 ok: true,494 data: vault.getEntries().filter((entry) => entry.passkey !== undefined),495 };496 case "DELETE_PASSKEY": {497 const { id } = msg.payload as { id: string };498 await vault.deleteEntry(id);499 return { ok: true };500 }501 case "GET_PASSKEY_PROVIDER_STATE":502 return { ok: true, data: await getPasskeyProviderState() };503 case "SET_PASSKEY_PROVIDER_ENABLED": {504 const { enabled } = msg.payload as { enabled: boolean };505 506 507 if (enabled) {508 const gate = await requirePro("passkeys");509 if (!gate.allowed) return { ok: false, error: "pro_required" };510 }511 return { ok: true, data: await setPasskeyProviderEnabled(enabled) };512 }513 case "FIRE_CANARY_PING": {514 const { webhookUrl, entryTitle, trigger } = msg.payload as {515 webhookUrl: string;516 entryTitle: string;517 trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";518 };519 if (!isAllowedCanaryWebhookUrl(webhookUrl)) {520 return { ok: false, error: "invalid webhook url" };521 }522 const body = JSON.stringify({523 event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",524 entry: entryTitle,525 trigger,526 timestamp: new Date().toISOString(),527 source: "peach-extension",528 });529 if (trigger === "test") {530 531 532 533 534 535 try {536 const res = await fetch(webhookUrl, {537 method: "POST",538 headers: { "Content-Type": "application/json" },539 body,540 });541 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };542 } catch (err) {543 return { ok: false, error: err instanceof Error ? err.message : "network error" };544 }545 }546 547 fetch(webhookUrl, {548 method: "POST",549 headers: { "Content-Type": "application/json" },550 body,551 }).catch(() => {});552 return { ok: true };553 }554 case "ACTIVATE_LICENSE": {555 const { key } = msg.payload as { key: string };556 const licenseInfo = await activateLicense(key);557 return { ok: true, data: licenseInfo };558 }559 case "GET_LICENSE": {560 const licenseInfo = await getLicenseInfo();561 return { ok: true, data: licenseInfo };562 }563 case "GET_LICENSE_KEY": {564 const key = await getLicenseKey();565 return { ok: true, data: key };566 }567 case "DEACTIVATE_LICENSE": {568 await deactivateLicense();569 return { ok: true };570 }571 case "SHOULD_PLAY_CELEBRATION": {572 const should = await shouldPlayCelebration();573 return { ok: true, data: should };574 }575 case "MARK_CELEBRATION_PLAYED": {576 await markCelebrationPlayed();577 return { ok: true };578 }579 case "OPEN_PRO_GATE": {580 581 const { feature } = (msg.payload ?? {}) as { feature?: string };582 if (chrome.storage.session) {583 await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });584 }585 try { await chrome.action.openPopup(); } catch { }586 return { ok: true };587 }588 case "GET_IDENTITIES": {589 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };590 return { ok: true, data: vault.getIdentities() };591 }592 case "GET_ADVANCED_HEALTH_REPORT": {593 const gate = await requirePro("advanced_health");594 if (!gate.allowed) return { ok: false, error: "pro_required" };595 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };596 const report = await buildAdvancedHealthReport(vault.getEntries());597 return { ok: true, data: report };598 }599 case "INITIATE_PASSWORD_CHANGE": {600 const gate = await requirePro("smart_password_change");601 if (!gate.allowed) return { ok: false, error: "pro_required" };602 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };603 const { id } = msg.payload as { id: string };604 const entry = vault.getEntryById(id);605 if (!entry) return { ok: false, error: "entry not found" };606 const url = resolveResetUrl(entry);607 if (!url) return { ok: false, error: "no reset url" };608 try {609 await chrome.tabs.create({ url });610 return { ok: true, data: { url } };611 } catch (err) {612 return {613 ok: false,614 error: err instanceof Error ? err.message : "failed to open tab",615 };616 }617 }618 case "IS_DURESS_ENABLED": {619 const enabled = await vault.isDuressEnabled();620 return { ok: true, data: enabled };621 }622 case "SETUP_DURESS": {623 const { duressPassword } = msg.payload as { duressPassword: string };624 await vault.setupDuress(duressPassword);625 return { ok: true };626 }627 case "DISABLE_DURESS": {628 await vault.disableDuress();629 return { ok: true };630 }631 case "VERIFY_MAIN_PASSWORD": {632 const { password } = msg.payload as { password: string };633 const ok = await vault.verifyMainPassword(password);634 return { ok: true, data: ok };635 }636 case "CHANGE_PASSWORD": {637 const { currentPassword, newPassword } = msg.payload as {638 currentPassword: string;639 newPassword: string;640 };641 const changed = await vault.changePassword(currentPassword, newPassword);642 return changed643 ? { ok: true }644 : { ok: false, error: "incorrect current password" };645 }646 case "GET_SITE_PASSWORD_POLICY": {647 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });648 if (!tab?.id) return { ok: true, data: null };649 try {650 const response = await chrome.tabs.sendMessage(tab.id, {651 type: "GET_SITE_PASSWORD_POLICY",652 });653 return (response as MessageResponse) ?? { ok: true, data: null };654 } catch {655 return { ok: true, data: null };656 }657 }658 case "GET_SYNC_STATE": {659 const { getPairedDevices, getTrustedNetworks } = await import('./sync-storage');660 const { isOnTailscale } = await import('./sync-network');661 const { isProUser } = await import('./license');662 const { storageGet } = await import('../shared/storage');663 const pairedDevices = await getPairedDevices();664 const trustedNetworks = await getTrustedNetworks();665 const tailscale = await isOnTailscale();666 const pro = await isProUser();667668 let status: SyncStatus = "offline";669 if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {670 if (pairedDevices.length === 0) {671 status = "no_peers";672 } else {673 status = "synced";674 }675 } else {676 status = "not_trusted";677 }678679 const lastSyncRaw = await storageGet("peach_last_sync_at");680 const lastSyncParsed = lastSyncRaw ? Number.parseInt(lastSyncRaw, 10) : NaN;681 const lastSyncAt = Number.isFinite(lastSyncParsed) ? lastSyncParsed : undefined;682683 return { ok: true, data: { pairedDevices, trustedNetworks, status, lastSyncAt, isPro: pro } };684 }685 case "TRIGGER_SYNC": {686 const gate = await requirePro("sync");687 if (!gate.allowed) return { ok: false, error: "pro_required" };688 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };689690 const { getPairedDevices, updatePairedDevice } = await import('./sync-storage');691 const paired = await getPairedDevices();692 if (paired.length === 0) return { ok: false, error: "no_paired_devices" };693694 const lifecycle = await import('./sync-lifecycle');695 const sync = await import('./sync');696 const { storageGet, storageSet } = await import('../shared/storage');697 const { createNativeSyncTransport, runBlobSyncSession } = await import('./sync-coordinator');698699 const discovered = lifecycle.getDiscoveredPeerCache();700 const reachable = paired.filter((device) =>701 discovered.some((peer) => peer.x25519PublicKey === device.x25519PublicKey),702 );703 if (reachable.length === 0) return { ok: false, error: "no_devices_reachable" };704705 706 707 708 const results: Array<{709 deviceName: string;710 status: "completed" | "errored" | "closed";711 error?: string;712 }> = [];713 for (const device of reachable) {714 const peer = discovered.find((p) => p.x25519PublicKey === device.x25519PublicKey)!;715 try {716 const connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);717 const transport = await createNativeSyncTransport(connectionId);718 const result = await runBlobSyncSession("initiator", transport, {719 isPro: async () => true,720 isUnlocked: () => vault.isUnlocked(),721 getVaultBlobs: async () => ({722 vault_a: (await storageGet("peach_vault_a")) ?? "",723 vault_b: (await storageGet("peach_vault_b")) ?? "",724 }),725 });726 results.push({727 deviceName: device.displayName,728 status: result.status,729 error: result.error,730 });731 if (result.status === "completed") {732 await updatePairedDevice(device.deviceId, {733 lastSeenAt: Date.now(),734 lastSyncAt: Date.now(),735 });736 }737 } catch (err) {738 results.push({739 deviceName: device.displayName,740 status: "errored",741 error: err instanceof Error ? err.message : String(err),742 });743 }744 }745746 await storageSet("peach_last_sync_at", String(Date.now()));747 return {748 ok: true,749 data: {750 results,751 syncedCount: results.filter((r) => r.status === "completed").length,752 totalReachable: reachable.length,753 },754 };755 }756 case "START_PAIRING": {757 const gate = await requirePro("sync");758 if (!gate.allowed) return { ok: false, error: "pro_required" };759 return { ok: true };760 }761 case "CANCEL_PAIRING": {762 return { ok: true };763 }764 case "ACCEPT_PAIRING": {765 const gate = await requirePro("sync");766 if (!gate.allowed) return { ok: false, error: "pro_required" };767 return { ok: true };768 }769 case "REMOVE_PAIRED_DEVICE": {770 const { deviceId } = msg.payload as { deviceId: string };771 const { removePairedDevice } = await import('./sync-storage');772 await removePairedDevice(deviceId);773 return { ok: true };774 }775 case "REMOVE_TRUSTED_NETWORK": {776 const { ssid, bssid, isTailscale } = msg.payload as any;777 const { removeTrustedNetwork, getTrustedNetworks } = await import('./sync-storage');778 const networks = await getTrustedNetworks();779 const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 780 (isTailscale ? n.isTailscale : n.ssid === ssid) && 781 (!bssid || n.bssid === bssid)782 );783 if (network) {784 await removeTrustedNetwork(network.addedAt);785 }786 return { ok: true };787 }788 case "MARK_NETWORK_TRUSTED": {789 const { addTrustedNetwork } = await import('./sync-storage');790 const { getCurrentNetwork } = await import('./sync-network');791 const network = await getCurrentNetwork();792 await addTrustedNetwork({793 ssid: network.ssid,794 bssid: network.bssid,795 isTailscale: network.isTailscale,796 addedAt: Date.now(),797 });798 return { ok: true };799 }800 case "GET_INCOMING_PAIRING_REQUEST": {801 const { getIncomingPairingRequest } = await import('./sync-storage');802 const request = await getIncomingPairingRequest();803 return { ok: true, data: request };804 }805 case "RESPOND_INCOMING_PAIRING": {806 const { accept } = msg.payload as { accept: boolean };807 const { getIncomingPairingRequest, clearIncomingPairingRequest, addPairedDevice } =808 await import('./sync-storage');809 const request = await getIncomingPairingRequest();810 if (!request) return { ok: false, error: "no_pending_request" };811 if (Date.now() > request.expiresAt) {812 await clearIncomingPairingRequest();813 return { ok: false, error: "expired" };814 }815816 const sync = await import('./sync');817 const { encodeSyncMessage } = await import('../shared/sync-wire');818819 820 821 await clearIncomingPairingRequest();822823 if (request.connectionId) {824 try {825 if (accept) {826 const ourKey = await sync.getIdentityPublicKey();827 const ourName = await getLocalDeviceName();828 sync.sendSyncData(829 request.connectionId,830 encodeSyncMessage({831 type: "PAIR_ACCEPT",832 public_key_hex: ourKey,833 device_name: ourName,834 }),835 );836 await sync.trustDevice(request.publicKeyHex, request.deviceName);837 await addPairedDevice({838 deviceId: request.publicKeyHex,839 displayName: request.deviceName,840 x25519PublicKey: request.publicKeyHex,841 pairedAt: Date.now(),842 lastSeenAt: Date.now(),843 });844 } else {845 sync.sendSyncData(846 request.connectionId,847 encodeSyncMessage({ type: "PAIR_REJECT", reason: "user_declined" }),848 );849 }850 } catch (err) {851 852 853 void err;854 }855 try { sync.disconnectFromPeer(request.connectionId); } catch { }856 } else if (accept) {857 858 try {859 await sync.trustDevice(request.publicKeyHex, request.deviceName);860 } catch {861 862 }863 }864865 return { ok: true, data: { accepted: accept, deviceName: request.deviceName } };866 }867 case "START_PEER_DISCOVERY": {868 const gate = await requirePro("sync");869 if (!gate.allowed) return { ok: false, error: "pro_required" };870 try {871 await startPeerDiscoveryForPopup();872 return { ok: true };873 } catch (err) {874 return { ok: false, error: err instanceof Error ? err.message : "discovery_failed" };875 }876 }877 case "STOP_PEER_DISCOVERY": {878 try {879 await stopPeerDiscoveryForPopup();880 } catch {881 882 }883 return { ok: true };884 }885 case "START_PAIRING_SERVER": {886 if (Capacitor.isNativePlatform()) {887 const gate = await requirePro("sync");888 if (!gate.allowed) return { ok: false, error: "pro_required" };889 try {890 const { getAndroidSyncIdentity } = await import("../shared/android-sync-identity");891 const { getOrCreateLocalDeviceId } = await import("./sync-identity-glue");892 const { startCapacitorPairingServer } = await import(893 "../shared/capacitor-pairing-server"894 );895 const { addPairedDevice } = await import("./sync-storage");896 const { setActivePairingToken, setRecentPairing } = await import(897 "./pairing-server-state"898 );899900 const identity = await getAndroidSyncIdentity();901 const publicKeyHex = Array.from(identity.publicKey, (b) =>902 b.toString(16).padStart(2, "0"),903 ).join("");904 const deviceId = await getOrCreateLocalDeviceId();905 const name = await getLocalDeviceName();906907 const token = generateSixDigitToken();908 const expiresAt = Date.now() + PAIRING_TOKEN_TTL_MS;909910 const handle = await startCapacitorPairingServer({911 privateKey: identity.privateKey,912 publicKey: identity.publicKey,913 publicKeyHex,914 deviceId,915 deviceName: name,916 token,917 onPaired: async (paired) => {918 try {919 await addPairedDevice({920 deviceId: paired.deviceId,921 displayName: paired.displayName,922 x25519PublicKey: paired.publicKeyHex,923 pairedAt: Date.now(),924 lastSeenAt: Date.now(),925 });926 } catch {927 928 }929 await setRecentPairing({930 deviceName: paired.displayName,931 pairedAt: Date.now(),932 side: "showing",933 });934 await setActivePairingToken(null);935 activeAndroidPairingHandle = null;936 },937 });938 activeAndroidPairingHandle = handle;939 await setActivePairingToken({ token, expiresAt });940 return { ok: true, data: { token, expiresAt } };941 } catch (err) {942 return { ok: false, error: err instanceof Error ? err.message : "pairing_server_failed" };943 }944 }945 const gate = await requirePro("sync");946 if (!gate.allowed) return { ok: false, error: "pro_required" };947 try {948 const sync = await import('./sync');949 const { setActivePairingToken } = await import('./pairing-server-state');950 const { getOrCreateLocalDeviceId } = await import('./sync-identity-glue');951 sync.connectSyncHost();952 const token = generateSixDigitToken();953 const expiresAt = Date.now() + PAIRING_TOKEN_TTL_MS;954 const deviceId = await getOrCreateLocalDeviceId();955 const name = await getLocalDeviceName();956 await sync.startPairingServer({ token, deviceId, name });957 await setActivePairingToken({ token, expiresAt });958 return { ok: true, data: { token, expiresAt } };959 } catch (err) {960 return { ok: false, error: err instanceof Error ? err.message : "pairing_server_failed" };961 }962 }963 case "STOP_PAIRING_SERVER": {964 if (Capacitor.isNativePlatform()) {965 try {966 const { setActivePairingToken } = await import("./pairing-server-state");967 await setActivePairingToken(null);968 if (activeAndroidPairingHandle) {969 await activeAndroidPairingHandle.stop();970 activeAndroidPairingHandle = null;971 }972 } catch {973 974 }975 return { ok: true };976 }977 try {978 const sync = await import('./sync');979 const { setActivePairingToken } = await import('./pairing-server-state');980 await setActivePairingToken(null);981 if (sync.isHostConnected()) {982 await sync.stopPairingServer();983 }984 } catch {985 986 }987 return { ok: true };988 }989 case "CONNECT_WITH_CODE": {990 if (Capacitor.isNativePlatform()) {991 const gate = await requirePro("sync");992 if (!gate.allowed) return { ok: false, error: "pro_required" };993 const { token: rawToken } = msg.payload as { token: string };994 const token = String(rawToken).replace(/\D/g, "");995 if (token.length !== 6) return { ok: false, error: "invalid_code" };996 try {997 const { registerPlugin } = await import("@capacitor/core");998 const Sync = registerPlugin<{999 resolvePeerByCode(opts: { token: string; timeoutMs?: number }): Promise<{1000 host: string;1001 tcpPort: number;1002 wsPort?: number;1003 x25519PublicKey: string;1004 deviceId: string;1005 name: string;1006 }>;1007 }>("Sync");1008 const resolved = await Sync.resolvePeerByCode({ token, timeoutMs: 8_000 });1009 if (resolved.wsPort === undefined) {1010 return { ok: false, error: "peer_missing_ws_port" };1011 }1012 const peer = {1013 deviceId: resolved.deviceId,1014 deviceName: resolved.name,1015 host: resolved.host,1016 port: resolved.tcpPort,1017 wsPort: resolved.wsPort,1018 x25519PublicKey: resolved.x25519PublicKey,1019 alreadyPaired: false,1020 };1021 const { getAndroidSyncIdentity } = await import("../shared/android-sync-identity");1022 const identity = await getAndroidSyncIdentity();1023 const ourPublicKeyHex = Array.from(identity.publicKey, (b) =>1024 b.toString(16).padStart(2, "0"),1025 ).join("");1026 const ourDeviceName = await getLocalDeviceName();1027 const { runCapacitorPairingHandshake } = await import(1028 "../shared/capacitor-pairing-handshake"1029 );1030 const { addPairedDevice } = await import("./sync-storage");1031 const { setRecentPairing } = await import("./pairing-server-state");1032 const result = await runCapacitorPairingHandshake(1033 peer,1034 identity.privateKey,1035 identity.publicKey,1036 ourPublicKeyHex,1037 ourDeviceName,1038 );1039 await addPairedDevice({1040 deviceId: peer.deviceId,1041 displayName: result.deviceName || peer.deviceName,1042 x25519PublicKey: result.publicKeyHex,1043 pairedAt: Date.now(),1044 lastSeenAt: Date.now(),1045 });1046 await setRecentPairing({1047 deviceName: result.deviceName || peer.deviceName,1048 pairedAt: Date.now(),1049 side: "entering",1050 });1051 return { ok: true, data: { deviceName: result.deviceName || peer.deviceName } };1052 } catch (err) {1053 const msg = err instanceof Error ? err.message : String(err);1054 if (msg.startsWith("pair_rejected:")) {1055 return { ok: false, error: "pair_rejected", data: { reason: msg.slice("pair_rejected:".length) } };1056 }1057 if (msg.includes("no peer responded") || msg.includes("resolve failed")) {1058 return { ok: false, error: msg };1059 }1060 return { ok: false, error: msg || "connect_failed" };1061 }1062 }1063 const gate = await requirePro("sync");1064 if (!gate.allowed) return { ok: false, error: "pro_required" };1065 const { token: rawToken } = msg.payload as { token: string };1066 const token = String(rawToken).replace(/\D/g, "");1067 if (token.length !== 6) return { ok: false, error: "invalid_code" };10681069 const sync = await import('./sync');1070 const { encodeSyncMessage } = await import('../shared/sync-wire');1071 const { waitForPairingResponse } = await import('./sync-coordinator');1072 const { addPairedDevice } = await import('./sync-storage');1073 const { setRecentPairing } = await import('./pairing-server-state');10741075 sync.connectSyncHost();1076 let connectionId: string | undefined;1077 try {1078 const peer = await sync.resolvePeerByCode(token, 8_000);1079 connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);10801081 const ourPublicKeyHex = await sync.getIdentityPublicKey();1082 const deviceName = await getLocalDeviceName();10831084 sync.sendSyncData(1085 connectionId,1086 encodeSyncMessage({1087 type: "PAIR_REQUEST",1088 public_key_hex: ourPublicKeyHex,1089 device_name: deviceName,1090 }),1091 );10921093 const response = await waitForPairingResponse(connectionId, 30_000);10941095 if (response.type === "PAIR_ACCEPT") {1096 await sync.trustDevice(response.public_key_hex, response.device_name);1097 await addPairedDevice({1098 deviceId: peer.deviceId,1099 displayName: response.device_name || peer.name,1100 x25519PublicKey: response.public_key_hex,1101 pairedAt: Date.now(),1102 lastSeenAt: Date.now(),1103 });1104 sync.disconnectFromPeer(connectionId);1105 1106 1107 await setRecentPairing({1108 deviceName: response.device_name || peer.name,1109 pairedAt: Date.now(),1110 side: "entering",1111 });1112 return { ok: true, data: { deviceName: response.device_name || peer.name } };1113 }11141115 if (response.type === "PAIR_REJECT") {1116 sync.disconnectFromPeer(connectionId);1117 return { ok: false, error: "pair_rejected", data: { reason: response.reason } };1118 }11191120 sync.disconnectFromPeer(connectionId);1121 return { ok: false, error: "unexpected_response" };1122 } catch (err) {1123 if (connectionId) {1124 try { sync.disconnectFromPeer(connectionId); } catch { }1125 }1126 return { ok: false, error: err instanceof Error ? err.message : "connect_failed" };1127 }1128 }1129 case "GET_RECENT_PAIRING": {1130 const { consumeRecentPairing } = await import('./pairing-server-state');1131 const recent = await consumeRecentPairing();1132 return { ok: true, data: recent };1133 }1134 case "INITIATE_PAIR_WITH_PEER": {1135 const gate = await requirePro("sync");1136 if (!gate.allowed) return { ok: false, error: "pro_required" };1137 const { peer } = msg.payload as {1138 peer: { deviceId: string; deviceName: string; host: string; port: number; x25519PublicKey: string };1139 };11401141 const sync = await import('./sync');1142 const { encodeSyncMessage } = await import('../shared/sync-wire');1143 const { waitForPairingResponse } = await import('./sync-coordinator');1144 const { addPairedDevice } = await import('./sync-storage');11451146 let connectionId: string | undefined;1147 try {1148 1149 1150 1151 1152 1153 connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);11541155 const ourPublicKeyHex = await sync.getIdentityPublicKey();1156 const deviceName = await getLocalDeviceName();11571158 sync.sendSyncData(1159 connectionId,1160 encodeSyncMessage({1161 type: "PAIR_REQUEST",1162 public_key_hex: ourPublicKeyHex,1163 device_name: deviceName,1164 }),1165 );11661167 const response = await waitForPairingResponse(connectionId, 30_000);11681169 if (response.type === "PAIR_ACCEPT") {1170 await sync.trustDevice(response.public_key_hex, response.device_name);1171 await addPairedDevice({1172 deviceId: peer.deviceId,1173 displayName: response.device_name || peer.deviceName,1174 x25519PublicKey: response.public_key_hex,1175 pairedAt: Date.now(),1176 lastSeenAt: Date.now(),1177 });1178 sync.disconnectFromPeer(connectionId);1179 return { ok: true, data: { connectionId, deviceName: response.device_name } };1180 }11811182 if (response.type === "PAIR_REJECT") {1183 sync.disconnectFromPeer(connectionId);1184 return { ok: false, error: "pair_rejected", data: { reason: response.reason } };1185 }11861187 sync.disconnectFromPeer(connectionId);1188 return { ok: false, error: "unexpected_response" };1189 } catch (err) {1190 if (connectionId) {1191 try { sync.disconnectFromPeer(connectionId); } catch { }1192 }1193 return { ok: false, error: err instanceof Error ? err.message : "pair_failed" };1194 }1195 }1196 case "DEBUG_INJECT_INCOMING_PAIRING": {1197 1198 1199 1200 1201 const { deviceName, publicKeyHex } = (msg.payload ?? {}) as {1202 deviceName?: string;1203 publicKeyHex?: string;1204 };1205 if (!deviceName || !publicKeyHex) {1206 return { ok: false, error: "missing_payload" };1207 }1208 const { setIncomingPairingRequest } = await import('./sync-storage');1209 const now = Date.now();1210 await setIncomingPairingRequest({1211 deviceName,1212 publicKeyHex,1213 receivedAt: now,1214 expiresAt: now + 60_000,1215 });1216 return { ok: true };1217 }1218 case "RESET_SYNC_IDENTITY": {1219 const gate = await requirePro("sync");1220 if (!gate.allowed) return { ok: false, error: "pro_required" };1221 const { getPairedDevices, removePairedDevice } = await import('./sync-storage');1222 const devices = await getPairedDevices();1223 for (const device of devices) {1224 await removePairedDevice(device.deviceId);1225 }1226 1227 1228 1229 1230 let nativePublicKey: string | null = null;1231 try {1232 const { rotateNativeIdentity, disconnectSyncHost } = await import('./sync');1233 nativePublicKey = await rotateNativeIdentity();1234 disconnectSyncHost();1235 } catch {1236 nativePublicKey = null;1237 }1238 return { ok: true, data: { removedDevices: devices.length, nativePublicKey } };1239 }1240 case "RECORD_MANUAL_SUMMON": {1241 const { domain } = msg.payload as { domain: string };1242 const settings = vault.getSettings();1243 const existing = settings.manualSummonDomains?.[domain];1244 const now = Date.now();1245 await vault.updateSettings({1246 manualSummonDomains: {1247 ...settings.manualSummonDomains,1248 [domain]: {1249 firstSummonedAt: existing?.firstSummonedAt ?? now,1250 summonCount: (existing?.summonCount ?? 0) + 1,1251 lastSummonedAt: now,1252 },1253 },1254 });1255 return { ok: true };1256 }1257 case "DECLINE_CARD_SAVE": {1258 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1259 await vault.declineCardSaveOffer(last4, expMonth, expYear);1260 return { ok: true };1261 }1262 case "IS_CARD_DECLINED": {1263 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1264 const declined = await vault.isCardDeclined(last4, expMonth, expYear);1265 return { ok: true, data: declined };1266 }1267 case "INITIATE_FIREFOX_PASSKEY_CREATE": {1268 const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {1269 rpId?: string;1270 rpName?: string;1271 userName?: string;1272 userDisplayName?: string;1273 };1274 if (!rpId || !userName) return { ok: false, error: "rpId and userName required" };1275 return await initiateFirefoxPasskeyCreate({1276 rpId,1277 rpName: rpName ?? rpId,1278 userName,1279 userDisplayName,1280 });1281 }12821283 case "COMPLETE_FIREFOX_PASSKEY_CREATE": {1284 const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;1285 if (!payload) return { ok: false, error: "missing payload" };1286 return await completeFirefoxPasskeyCreate(payload);1287 }12881289 case "PASSKEY_OFFER_DECLINE": {1290 const { domain } = (msg.payload ?? {}) as { domain?: string };1291 if (!domain) return { ok: false, error: "domain required" };1292 await recordPasskeyOfferDecline(domain);1293 return { ok: true };1294 }12951296 case "GET_PASSKEY_OFFER_DECLINED": {1297 const { domain } = (msg.payload ?? {}) as { domain?: string };1298 if (!domain) return { ok: false, error: "domain required" };1299 return { ok: true, data: await isPasskeyOfferDeclined(domain) };1300 }13011302 case "PASSKEY_HAS_CREDENTIALS": {1303 const payload = msg.payload as {1304 rpId?: string;1305 allowCredentials?: Array<{ id: string; type?: string }>;1306 } | undefined;1307 if (!payload?.rpId) return { ok: false, error: "rpId required" };1308 return await handlePasskeyHasCredentials({1309 rpId: payload.rpId,1310 allowCredentials: payload.allowCredentials ?? [],1311 });1312 }13131314 case "PASSKEY_CREATE": {1315 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;1316 if (!payload?.rpId || !payload?.userName) {1317 return { ok: false, error: "rpId and userName required" };1318 }1319 return await handleFirefoxPasskeyCreate(payload);1320 }13211322 case "PASSKEY_GET": {1323 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;1324 if (!payload?.rpId) return { ok: false, error: "rpId required" };1325 return await handleFirefoxPasskeyGet(payload);1326 }13271328 case "GET_PASSKEY_FOR_DOMAIN": {1329 const { domain } = (msg.payload ?? {}) as { domain?: string };1330 if (!domain) return { ok: false, error: "domain required" };1331 if (!vault.isUnlocked()) return { ok: true, data: null };1332 const match = vault1333 .getEntries()1334 .find((entry) => entry.passkey?.rpId === domain);1335 if (!match || !match.passkey) return { ok: true, data: null };1336 1337 1338 return {1339 ok: true,1340 data: {1341 rpId: match.passkey.rpId,1342 userName: match.passkey.userName,1343 browser: match.passkey.browser,1344 },1345 };1346 }13471348 case "DESTROY_VAULT": {1349 1350 1351 1352 1353 1354 vault.lock();1355 await storageClear();1356 1357 1358 1359 1360 1361 await vault.clearVaultHistory();1362 if (!Capacitor.isNativePlatform() && chrome.storage.session) {1363 await chrome.storage.session.clear();1364 }1365 return { ok: true };1366 }13671368 case "SUBMIT_FEEDBACK": {1369 const { category, message, version, browser } = msg.payload as {1370 category: string;1371 message: string;1372 version: string;1373 browser: string;1374 };1375 if (!message.trim()) return { ok: false, error: "message is empty" };1376 if (!FEEDBACK_ENDPOINT) return { ok: true };1377 if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {1378 return { ok: false, error: "invalid feedback endpoint" };1379 }1380 try {1381 const res = await fetch(FEEDBACK_ENDPOINT, {1382 method: "POST",1383 headers: { "Content-Type": "application/json" },1384 body: JSON.stringify({ category, message, version, browser, source: "peach-extension" }),1385 });1386 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };1387 } catch (err) {1388 return { ok: false, error: err instanceof Error ? err.message : "network error" };1389 }1390 }1391 default:1392 return { ok: false, error: "unknown message type" };1393 }1394 } catch (err) {1395 return {1396 ok: false,1397 error: err instanceof Error ? err.message : "operation failed",1398 };1399 }1400}140114021403function broadcastToContentScripts(message: { type: string }): void {1404 if (Capacitor.isNativePlatform() || !chrome.tabs?.query) return;1405 chrome.tabs.query({}, (tabs) => {1406 for (const tab of tabs) {1407 if (tab.id !== undefined) {1408 chrome.tabs.sendMessage(tab.id, message).catch(() => {1409 1410 });1411 }1412 }1413 });1414}14151416function shouldRefreshAutoLock(type: MessageType): boolean {1417 return (1418 type !== "HAS_VAULT" &&1419 type !== "IS_UNLOCKED" &&1420 type !== "GET_VAULT_STATUS" &&1421 type !== "LOCK" &&1422 type !== "UPDATE_SETTINGS" &&1423 type !== "GET_PENDING_PASSKEY" &&1424 type !== "GET_PASSKEY_PROVIDER_STATE" &&1425 type !== "DECLINE_CARD_SAVE" &&1426 type !== "IS_CARD_DECLINED" &&1427 type !== "SUBMIT_FEEDBACK"1428 );1429}14301431function isAllowedCanaryWebhookUrl(value: string): boolean {1432 try {1433 const url = new URL(value);1434 if (url.protocol !== "https:") return false;14351436 const hostname = url.hostname.toLowerCase();1437 if (1438 hostname === "localhost" ||1439 hostname.endsWith(".localhost") ||1440 isPrivateIpv4(hostname) ||1441 isPrivateIpv6(hostname)1442 ) {1443 return false;1444 }14451446 return true;1447 } catch {1448 return false;1449 }1450}14511452function isPrivateIpv4(hostname: string): boolean {1453 if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {1454 return true;1455 }14561457 const octets = hostname.split(".").map((part) => Number(part));1458 if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {1459 return false;1460 }14611462 return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;1463}14641465function isPrivateIpv6(hostname: string): boolean {1466 const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();1467 return (1468 normalized === "::1" ||1469 normalized.startsWith("fc") ||1470 normalized.startsWith("fd") ||1471 normalized.startsWith("fe80:")1472 );1473}