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