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 return { ok: true };662 }663 case "START_PAIRING": {664 const gate = await requirePro("sync");665 if (!gate.allowed) return { ok: false, error: "pro_required" };666 return { ok: true };667 }668 case "CANCEL_PAIRING": {669 return { ok: true };670 }671 case "ACCEPT_PAIRING": {672 const gate = await requirePro("sync");673 if (!gate.allowed) return { ok: false, error: "pro_required" };674 return { ok: true };675 }676 case "REMOVE_PAIRED_DEVICE": {677 const { deviceId } = msg.payload as { deviceId: string };678 const { removePairedDevice } = await import('./sync-storage');679 await removePairedDevice(deviceId);680 return { ok: true };681 }682 case "REMOVE_TRUSTED_NETWORK": {683 const { ssid, bssid, isTailscale } = msg.payload as any;684 const { removeTrustedNetwork, getTrustedNetworks } = await import('./sync-storage');685 const networks = await getTrustedNetworks();686 const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 687 (isTailscale ? n.isTailscale : n.ssid === ssid) && 688 (!bssid || n.bssid === bssid)689 );690 if (network) {691 await removeTrustedNetwork(network.addedAt);692 }693 return { ok: true };694 }695 case "MARK_NETWORK_TRUSTED": {696 const { addTrustedNetwork } = await import('./sync-storage');697 const { getCurrentNetwork } = await import('./sync-network');698 const network = await getCurrentNetwork();699 await addTrustedNetwork({700 ssid: network.ssid,701 bssid: network.bssid,702 isTailscale: network.isTailscale,703 addedAt: Date.now(),704 });705 return { ok: true };706 }707 case "GET_INCOMING_PAIRING_REQUEST": {708 const { getIncomingPairingRequest } = await import('./sync-storage');709 const request = await getIncomingPairingRequest();710 return { ok: true, data: request };711 }712 case "RESPOND_INCOMING_PAIRING": {713 const { accept } = msg.payload as { accept: boolean };714 const { getIncomingPairingRequest, clearIncomingPairingRequest, addPairedDevice } =715 await import('./sync-storage');716 const request = await getIncomingPairingRequest();717 if (!request) return { ok: false, error: "no_pending_request" };718 if (Date.now() > request.expiresAt) {719 await clearIncomingPairingRequest();720 return { ok: false, error: "expired" };721 }722723 const sync = await import('./sync');724 const { encodeSyncMessage } = await import('../shared/sync-wire');725726 727 728 await clearIncomingPairingRequest();729730 if (request.connectionId) {731 try {732 if (accept) {733 const ourKey = await sync.getIdentityPublicKey();734 const ourName = await getLocalDeviceName();735 sync.sendSyncData(736 request.connectionId,737 encodeSyncMessage({738 type: "PAIR_ACCEPT",739 public_key_hex: ourKey,740 device_name: ourName,741 }),742 );743 await sync.trustDevice(request.publicKeyHex, request.deviceName);744 await addPairedDevice({745 deviceId: request.publicKeyHex,746 displayName: request.deviceName,747 x25519PublicKey: request.publicKeyHex,748 pairedAt: Date.now(),749 lastSeenAt: Date.now(),750 });751 } else {752 sync.sendSyncData(753 request.connectionId,754 encodeSyncMessage({ type: "PAIR_REJECT", reason: "user_declined" }),755 );756 }757 } catch (err) {758 759 760 void err;761 }762 try { sync.disconnectFromPeer(request.connectionId); } catch { }763 } else if (accept) {764 765 try {766 await sync.trustDevice(request.publicKeyHex, request.deviceName);767 } catch {768 769 }770 }771772 return { ok: true, data: { accepted: accept, deviceName: request.deviceName } };773 }774 case "START_PEER_DISCOVERY": {775 const gate = await requirePro("sync");776 if (!gate.allowed) return { ok: false, error: "pro_required" };777 try {778 await startPeerDiscoveryForPopup();779 return { ok: true };780 } catch (err) {781 return { ok: false, error: err instanceof Error ? err.message : "discovery_failed" };782 }783 }784 case "STOP_PEER_DISCOVERY": {785 try {786 await stopPeerDiscoveryForPopup();787 } catch {788 789 }790 return { ok: true };791 }792 case "INITIATE_PAIR_WITH_PEER": {793 const gate = await requirePro("sync");794 if (!gate.allowed) return { ok: false, error: "pro_required" };795 const { peer } = msg.payload as {796 peer: { deviceId: string; deviceName: string; host: string; port: number; x25519PublicKey: string };797 };798799 const sync = await import('./sync');800 const { encodeSyncMessage } = await import('../shared/sync-wire');801 const { waitForPairingResponse } = await import('./sync-coordinator');802 const { addPairedDevice } = await import('./sync-storage');803804 let connectionId: string | undefined;805 try {806 807 808 809 810 811 connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);812813 const ourPublicKeyHex = await sync.getIdentityPublicKey();814 const deviceName = await getLocalDeviceName();815816 sync.sendSyncData(817 connectionId,818 encodeSyncMessage({819 type: "PAIR_REQUEST",820 public_key_hex: ourPublicKeyHex,821 device_name: deviceName,822 }),823 );824825 const response = await waitForPairingResponse(connectionId, 30_000);826827 if (response.type === "PAIR_ACCEPT") {828 await sync.trustDevice(response.public_key_hex, response.device_name);829 await addPairedDevice({830 deviceId: peer.deviceId,831 displayName: response.device_name || peer.deviceName,832 x25519PublicKey: response.public_key_hex,833 pairedAt: Date.now(),834 lastSeenAt: Date.now(),835 });836 sync.disconnectFromPeer(connectionId);837 return { ok: true, data: { connectionId, deviceName: response.device_name } };838 }839840 if (response.type === "PAIR_REJECT") {841 sync.disconnectFromPeer(connectionId);842 return { ok: false, error: "pair_rejected", data: { reason: response.reason } };843 }844845 sync.disconnectFromPeer(connectionId);846 return { ok: false, error: "unexpected_response" };847 } catch (err) {848 if (connectionId) {849 try { sync.disconnectFromPeer(connectionId); } catch { }850 }851 return { ok: false, error: err instanceof Error ? err.message : "pair_failed" };852 }853 }854 case "DEBUG_INJECT_INCOMING_PAIRING": {855 856 857 858 859 const { deviceName, publicKeyHex } = (msg.payload ?? {}) as {860 deviceName?: string;861 publicKeyHex?: string;862 };863 if (!deviceName || !publicKeyHex) {864 return { ok: false, error: "missing_payload" };865 }866 const { setIncomingPairingRequest } = await import('./sync-storage');867 const now = Date.now();868 await setIncomingPairingRequest({869 deviceName,870 publicKeyHex,871 receivedAt: now,872 expiresAt: now + 60_000,873 });874 return { ok: true };875 }876 case "RESET_SYNC_IDENTITY": {877 const gate = await requirePro("sync");878 if (!gate.allowed) return { ok: false, error: "pro_required" };879 const { getPairedDevices, removePairedDevice } = await import('./sync-storage');880 const devices = await getPairedDevices();881 for (const device of devices) {882 await removePairedDevice(device.deviceId);883 }884 885 886 887 888 let nativePublicKey: string | null = null;889 try {890 const { rotateNativeIdentity, disconnectSyncHost } = await import('./sync');891 nativePublicKey = await rotateNativeIdentity();892 disconnectSyncHost();893 } catch {894 nativePublicKey = null;895 }896 return { ok: true, data: { removedDevices: devices.length, nativePublicKey } };897 }898 case "RECORD_MANUAL_SUMMON": {899 const { domain } = msg.payload as { domain: string };900 const settings = vault.getSettings();901 const existing = settings.manualSummonDomains?.[domain];902 const now = Date.now();903 await vault.updateSettings({904 manualSummonDomains: {905 ...settings.manualSummonDomains,906 [domain]: {907 firstSummonedAt: existing?.firstSummonedAt ?? now,908 summonCount: (existing?.summonCount ?? 0) + 1,909 lastSummonedAt: now,910 },911 },912 });913 return { ok: true };914 }915 case "DECLINE_CARD_SAVE": {916 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };917 await vault.declineCardSaveOffer(last4, expMonth, expYear);918 return { ok: true };919 }920 case "IS_CARD_DECLINED": {921 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };922 const declined = await vault.isCardDeclined(last4, expMonth, expYear);923 return { ok: true, data: declined };924 }925 case "INITIATE_FIREFOX_PASSKEY_CREATE": {926 const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {927 rpId?: string;928 rpName?: string;929 userName?: string;930 userDisplayName?: string;931 };932 if (!rpId || !userName) return { ok: false, error: "rpId and userName required" };933 return await initiateFirefoxPasskeyCreate({934 rpId,935 rpName: rpName ?? rpId,936 userName,937 userDisplayName,938 });939 }940941 case "COMPLETE_FIREFOX_PASSKEY_CREATE": {942 const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;943 if (!payload) return { ok: false, error: "missing payload" };944 return await completeFirefoxPasskeyCreate(payload);945 }946947 case "PASSKEY_OFFER_DECLINE": {948 const { domain } = (msg.payload ?? {}) as { domain?: string };949 if (!domain) return { ok: false, error: "domain required" };950 await recordPasskeyOfferDecline(domain);951 return { ok: true };952 }953954 case "GET_PASSKEY_OFFER_DECLINED": {955 const { domain } = (msg.payload ?? {}) as { domain?: string };956 if (!domain) return { ok: false, error: "domain required" };957 return { ok: true, data: await isPasskeyOfferDeclined(domain) };958 }959960 case "PASSKEY_HAS_CREDENTIALS": {961 const payload = msg.payload as {962 rpId?: string;963 allowCredentials?: Array<{ id: string; type?: string }>;964 } | undefined;965 if (!payload?.rpId) return { ok: false, error: "rpId required" };966 return await handlePasskeyHasCredentials({967 rpId: payload.rpId,968 allowCredentials: payload.allowCredentials ?? [],969 });970 }971972 case "PASSKEY_CREATE": {973 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;974 if (!payload?.rpId || !payload?.userName) {975 return { ok: false, error: "rpId and userName required" };976 }977 return await handleFirefoxPasskeyCreate(payload);978 }979980 case "PASSKEY_GET": {981 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;982 if (!payload?.rpId) return { ok: false, error: "rpId required" };983 return await handleFirefoxPasskeyGet(payload);984 }985986 case "GET_PASSKEY_FOR_DOMAIN": {987 const { domain } = (msg.payload ?? {}) as { domain?: string };988 if (!domain) return { ok: false, error: "domain required" };989 if (!vault.isUnlocked()) return { ok: true, data: null };990 const match = vault991 .getEntries()992 .find((entry) => entry.passkey?.rpId === domain);993 if (!match || !match.passkey) return { ok: true, data: null };994 995 996 return {997 ok: true,998 data: {999 rpId: match.passkey.rpId,1000 userName: match.passkey.userName,1001 browser: match.passkey.browser,1002 },1003 };1004 }10051006 case "DESTROY_VAULT": {1007 1008 1009 1010 1011 1012 vault.lock();1013 await storageClear();1014 1015 1016 1017 1018 1019 await vault.clearVaultHistory();1020 if (!Capacitor.isNativePlatform() && chrome.storage.session) {1021 await chrome.storage.session.clear();1022 }1023 return { ok: true };1024 }10251026 case "SUBMIT_FEEDBACK": {1027 const { category, message, version, browser } = msg.payload as {1028 category: string;1029 message: string;1030 version: string;1031 browser: string;1032 };1033 if (!message.trim()) return { ok: false, error: "message is empty" };1034 if (!FEEDBACK_ENDPOINT) return { ok: true };1035 if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {1036 return { ok: false, error: "invalid feedback endpoint" };1037 }1038 try {1039 const res = await fetch(FEEDBACK_ENDPOINT, {1040 method: "POST",1041 headers: { "Content-Type": "application/json" },1042 body: JSON.stringify({ category, message, version, browser, source: "peach-extension" }),1043 });1044 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };1045 } catch (err) {1046 return { ok: false, error: err instanceof Error ? err.message : "network error" };1047 }1048 }1049 default:1050 return { ok: false, error: "unknown message type" };1051 }1052 } catch (err) {1053 return {1054 ok: false,1055 error: err instanceof Error ? err.message : "operation failed",1056 };1057 }1058}105910601061function broadcastToContentScripts(message: { type: string }): void {1062 if (Capacitor.isNativePlatform() || !chrome.tabs?.query) return;1063 chrome.tabs.query({}, (tabs) => {1064 for (const tab of tabs) {1065 if (tab.id !== undefined) {1066 chrome.tabs.sendMessage(tab.id, message).catch(() => {1067 1068 });1069 }1070 }1071 });1072}10731074function shouldRefreshAutoLock(type: MessageType): boolean {1075 return (1076 type !== "HAS_VAULT" &&1077 type !== "IS_UNLOCKED" &&1078 type !== "GET_VAULT_STATUS" &&1079 type !== "LOCK" &&1080 type !== "UPDATE_SETTINGS" &&1081 type !== "GET_PENDING_PASSKEY" &&1082 type !== "GET_PASSKEY_PROVIDER_STATE" &&1083 type !== "DECLINE_CARD_SAVE" &&1084 type !== "IS_CARD_DECLINED" &&1085 type !== "SUBMIT_FEEDBACK"1086 );1087}10881089function isAllowedCanaryWebhookUrl(value: string): boolean {1090 try {1091 const url = new URL(value);1092 if (url.protocol !== "https:") return false;10931094 const hostname = url.hostname.toLowerCase();1095 if (1096 hostname === "localhost" ||1097 hostname.endsWith(".localhost") ||1098 isPrivateIpv4(hostname) ||1099 isPrivateIpv6(hostname)1100 ) {1101 return false;1102 }11031104 return true;1105 } catch {1106 return false;1107 }1108}11091110function isPrivateIpv4(hostname: string): boolean {1111 if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {1112 return true;1113 }11141115 const octets = hostname.split(".").map((part) => Number(part));1116 if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {1117 return false;1118 }11191120 return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;1121}11221123function isPrivateIpv6(hostname: string): boolean {1124 const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();1125 return (1126 normalized === "::1" ||1127 normalized.startsWith("fc") ||1128 normalized.startsWith("fd") ||1129 normalized.startsWith("fe80:")1130 );1131}