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