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 { storageClear } from "../shared/storage";40import type {41 Message,42 MessageResponse,43 MessageType,44 ProFeature,45 VaultEntry,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 chrome.runtime65 .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })66 .catch(() => {67 68 });69 } else {70 71 72 73 if (chrome.storage.session) {74 chrome.storage.session75 .set({ pendingProNotice: { feature, triggeredAt: Date.now() } })76 .catch(() => {});77 }78 }7980 return { allowed: false };81}828384858687const DEVICE_NAME_KEY = "peach_device_name";8889async function getLocalDeviceName(): Promise<string> {90 try {91 const stored = await chrome.storage.local.get(DEVICE_NAME_KEY);92 const value = (stored as Record<string, string | undefined>)[DEVICE_NAME_KEY];93 if (typeof value === "string" && value.trim()) return value.trim();94 } catch {95 96 }97 return "Peach";98}99100101102103const PAIRING_TOKEN_TTL_MS = 5 * 60 * 1000;104105106function generateSixDigitToken(): string {107 108 109 110 111 const buf = new Uint32Array(1);112 crypto.getRandomValues(buf);113 const value = buf[0] % 1_000_000;114 return value.toString().padStart(6, "0");115}116117118119120const PRIVATE_IPV4_RANGES = [121 { prefix: "10." },122 { prefix: "127." },123 { prefix: "169.254." },124 { prefix: "192.168." },125];126127128129130131const FEEDBACK_ENDPOINT = "";132133export function setupMessageHandler(): void {134 chrome.runtime.onMessage.addListener(135 (msg: unknown, sender, sendResponse) => {136 if (!isMessage(msg)) {137 sendResponse({ ok: false, error: "invalid message" });138 return true;139 }140 handleMessage(msg, sender)141 .then(sendResponse)142 .catch((err) =>143 sendResponse({144 ok: false,145 error: err instanceof Error ? err.message : String(err),146 })147 );148 return true;149 }150 );151}152153function isMessage(m: unknown): m is Message {154 return typeof m === "object" && m !== null && "type" in m;155}156157export async function handleMessage(158 msg: Message,159 sender: Partial<chrome.runtime.MessageSender> = {}160): Promise<MessageResponse> {161 try {162 163 164 165 166 if (167 sender.id !== undefined &&168 sender.id !== chrome.runtime.id169 ) {170 return { ok: false, error: "invalid sender" };171 }172173 if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {174 touchAutoLock();175 }176177 switch (msg.type) {178 case "CREATE_VAULT": {179 const { password } = msg.payload as { password: string };180 await vault.createVault(password);181 await setupAutoLock();182 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });183 return { ok: true };184 }185 case "UNLOCK": {186 const { password } = msg.payload as { password: string };187 const ok = await vault.unlock(password);188 if (ok) {189 await setupAutoLock();190 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });191 }192 return { ok };193 }194 case "VALIDATE_RECOVERY_WORDS": {195 const { words } = msg.payload as { words: string[] };196 const { findInvalidWords } = await import("../shared/recovery-words");197 const invalid = findInvalidWords(words);198 return { ok: true, data: { valid: invalid.length === 0, invalid } };199 }200 case "GET_RECOVERY_WORDS": {201 202 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };203 try {204 const words = await vault.getRecoveryWords();205 return { ok: true, data: words };206 } catch (e) {207 208 return { ok: false, error: "recovery_not_setup" };209 }210 }211 case "RECOVER_WITH_WORDS": {212 213 214 const { words, newPassword } = msg.payload as {215 words: string[];216 newPassword: string;217 };218 const stored = await vault.loadSlotA();219 if (!stored) return { ok: false, error: "no vault" };220 if (!stored.recovery_salt || !stored.recovery_wrapped_root_key) {221 return { ok: false, error: "recovery_not_setup" };222 }223 224 try {225 await vault.unwrapRootKeyForRecovery(226 stored.recovery_wrapped_root_key,227 stored.recovery_nonce ?? "",228 stored.recovery_tag ?? "",229 stored.recovery_salt,230 words231 );232 } catch {233 return { ok: false, error: "invalid_recovery_words" };234 }235 236 const newSalt = vault.generateSalt();237 const newKey = await vault.deriveKey(newPassword, newSalt);238 239 240 await vault.rewrapRootKeyForPassword(newKey, newSalt);241 242 await vault.changePasswordWithNewKey(newSalt, newKey);243 await setupAutoLock();244 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });245 return { ok: true };246 }247 case "LOCK":248 vault.lock();249 return { ok: true };250 case "IS_UNLOCKED":251 return { ok: true, data: vault.isUnlocked() };252 case "GET_VAULT_STATUS":253 return { ok: true, unlocked: vault.isUnlocked() };254 case "HAS_VAULT":255 return { ok: true, data: await vault.hasVault() };256 case "GET_ENTRIES":257 return { ok: true, data: vault.getEntries() };258 case "ADD_ENTRY": {259 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };260 const entry = await vault.addEntry(msg.payload as any);261 return { ok: true, data: entry };262 }263 case "BATCH_ADD_ENTRIES": {264 const { entries } = msg.payload as { entries: any[] };265 const added = await vault.batchAddEntries(entries);266 return { ok: true, data: added };267 }268 case "RESTORE_ENTRIES": {269 const { entries, mode, settings } = msg.payload as {270 entries: any[];271 mode: "replace" | "merge";272 settings?: vault.VaultSettings;273 };274 const summary = await vault.restoreEntries(entries, mode);275 if (settings && typeof settings === "object") {276 await vault.updateSettings(settings);277 }278 return { ok: true, data: summary };279 }280 case "CLEAR_VAULT_ENTRIES": {281 await vault.clearVaultEntries("REPLACE_ALL");282 return { ok: true };283 }284 case "GET_VAULT_HISTORY": {285 const snapshots = await vault.getVaultHistory();286 return { ok: true, data: snapshots };287 }288 case "RESTORE_VAULT_SNAPSHOT": {289 const { snapshotId } = msg.payload as { snapshotId: string };290 const result = await vault.restoreVaultSnapshot(snapshotId);291 if (!result.ok) return { ok: false, error: result.error };292 broadcastToContentScripts({ type: "VAULT_LOCKED" });293 return { ok: true };294 }295 case "COUNT_DUPLICATES": {296 return { ok: true, data: vault.countDuplicates() };297 }298 case "DEDUPLICATE_ENTRIES": {299 const removed = await vault.deduplicateEntries();300 return { ok: true, data: removed };301 }302 case "UPDATE_ENTRY": {303 const { id, patch } = msg.payload as any;304 await vault.updateEntry(id, patch);305 return { ok: true };306 }307 case "ADD_ADDITIONAL_URL": {308 const { id, url } = msg.payload as { id: string; url: string };309 const trimmed = typeof url === "string" ? url.trim() : "";310 if (!trimmed) return { ok: false, error: "url required" };311 const existing = vault.getEntryById(id);312 if (!existing) return { ok: false, error: "entry not found" };313 if (existing.type !== "login") return { ok: false, error: "login entries only" };314 const merged = [...(existing.additionalUrls ?? []), trimmed];315 await vault.updateEntry(id, { additionalUrls: merged });316 return { ok: true };317 }318 case "DELETE_ENTRY": {319 const { id } = msg.payload as { id: string };320 await vault.deleteEntry(id);321 return { ok: true };322 }323 case "GET_DELETED_ENTRIES":324 return { ok: true, data: vault.getDeletedEntries() };325 case "RESTORE_ENTRY": {326 const { id } = msg.payload as { id: string };327 await vault.restoreEntry(id);328 return { ok: true };329 }330 case "PURGE_ENTRY": {331 const { id } = msg.payload as { id: string };332 await vault.purgeEntry(id);333 return { ok: true };334 }335 case "PURGE_EXPIRED_TRASH": {336 const { retentionMs } = msg.payload as { retentionMs: number };337 const purged = await vault.purgeExpiredTrash(retentionMs);338 return { ok: true, data: purged };339 }340 case "AUTOFILL_QUERY": {341 if (!vault.isUnlocked()) {342 return { ok: false, error: "VAULT_LOCKED" };343 }344 const { domain, includeAll, kind } = msg.payload as {345 domain: string;346 includeAll?: boolean;347 kind?: "login" | "card";348 };349 350 351 352 353 if (kind === "card") {354 const cards = vault.getEntries().filter((e) => e.type === "card");355 return {356 ok: true,357 data: cards.map((e) => ({358 id: e.id,359 title: e.cardNickname || e.title,360 cardholderName: e.cardholderName,361 last4: e.last4,362 expMonth: e.expMonth,363 expYear: e.expYear,364 network: e.network,365 })),366 };367 }368 369 370 371 372 373 const matches = (374 includeAll375 ? vault376 .getEntries()377 .filter((entry) => entry.type !== "note" && entry.type !== "card")378 : vault.queryDomain(domain)379 ).filter((entry) => !isPasskeyOnly(entry));380 return {381 ok: true,382 data: matches.map((e) =>383 includeAll384 ? {385 id: e.id,386 title: e.title,387 hasPasskey: e.passkey !== undefined,388 }389 : {390 id: e.id,391 title: e.title,392 username: e.username,393 url: e.url,394 hasTotp: Boolean(e.totp),395 hasPasskey: e.passkey !== undefined,396 }397 ),398 };399 }400 case "GET_ENTRY_FOR_FILL": {401 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };402 const { id } = msg.payload as { id: string };403 const entry = vault.getEntryById(id);404 if (!entry) return { ok: false, error: "entry not found" };405 406 407 408 if (isPasskeyOnly(entry)) return { ok: false, error: "passkey not fillable" };409 410 411 412 const { passkey: _strippedPasskey, ...safeEntry } = entry;413 return { ok: true, data: safeEntry };414 }415 case "COPY": {416 const { text, label, alreadyWritten } = msg.payload as {417 text: string;418 label: string;419 alreadyWritten?: boolean;420 };421 await copyToClipboard(text, label, Boolean(alreadyWritten));422 return { ok: true };423 }424 case "CHECK_BREACH": {425 const { password } = msg.payload as { password: string };426 const count = await vault.checkBreach(password);427 return { ok: true, data: count };428 }429 case "CHECK_ALL_BREACHES": {430 const allEntries = vault.getEntries();431 const results: Record<string, number> = {};432 for (const entry of allEntries) {433 if (entry.type === "note") continue;434 if (!entry.password) continue;435 results[entry.id] = await vault.checkBreach(entry.password);436 437 await new Promise((r) => setTimeout(r, 200));438 }439 440 for (const [id, count] of Object.entries(results)) {441 const entry = vault.getEntryById(id);442 if (entry && (entry.breach ?? 0) !== count) {443 await vault.updateEntry(id, { breach: count > 0 ? count : undefined });444 }445 }446 return { ok: true, data: results };447 }448 case "GET_SETTINGS":449 return { ok: true, data: await vault.ensureSettingsLoaded() };450 case "UPDATE_SETTINGS": {451 const patch = msg.payload as Record<string, unknown>;452 await vault.updateSettings(patch as any);453 if (typeof patch.autoLockMinutes === "number") {454 await setupAutoLock();455 if (vault.isUnlocked()) touchAutoLock();456 }457 return { ok: true };458 }459 case "GET_PENDING_PASSKEY":460 return { ok: true, data: await getPendingPasskeyRequest() };461 case "APPROVE_PASSKEY": {462 const { requestId } = msg.payload as { requestId: number };463 await resolvePendingPasskeyRequest(requestId, true);464 return { ok: true };465 }466 case "DENY_PASSKEY": {467 const { requestId } = msg.payload as { requestId: number };468 await resolvePendingPasskeyRequest(requestId, false);469 return { ok: true };470 }471 case "GET_PASSKEYS":472 return {473 ok: true,474 data: vault.getEntries().filter((entry) => entry.passkey !== undefined),475 };476 case "DELETE_PASSKEY": {477 const { id } = msg.payload as { id: string };478 await vault.deleteEntry(id);479 return { ok: true };480 }481 case "GET_PASSKEY_PROVIDER_STATE":482 return { ok: true, data: await getPasskeyProviderState() };483 case "SET_PASSKEY_PROVIDER_ENABLED": {484 const { enabled } = msg.payload as { enabled: boolean };485 486 487 if (enabled) {488 const gate = await requirePro("passkeys");489 if (!gate.allowed) return { ok: false, error: "pro_required" };490 }491 return { ok: true, data: await setPasskeyProviderEnabled(enabled) };492 }493 case "FIRE_CANARY_PING": {494 const { webhookUrl, entryTitle, trigger } = msg.payload as {495 webhookUrl: string;496 entryTitle: string;497 trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";498 };499 if (!isAllowedCanaryWebhookUrl(webhookUrl)) {500 return { ok: false, error: "invalid webhook url" };501 }502 const body = JSON.stringify({503 event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",504 entry: entryTitle,505 trigger,506 timestamp: new Date().toISOString(),507 source: "peach-extension",508 });509 if (trigger === "test") {510 511 512 513 514 515 try {516 const res = await fetch(webhookUrl, {517 method: "POST",518 headers: { "Content-Type": "application/json" },519 body,520 });521 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };522 } catch (err) {523 return { ok: false, error: err instanceof Error ? err.message : "network error" };524 }525 }526 527 fetch(webhookUrl, {528 method: "POST",529 headers: { "Content-Type": "application/json" },530 body,531 }).catch(() => {});532 return { ok: true };533 }534 case "ACTIVATE_LICENSE": {535 const { key } = msg.payload as { key: string };536 const licenseInfo = await activateLicense(key);537 return { ok: true, data: licenseInfo };538 }539 case "GET_LICENSE": {540 const licenseInfo = await getLicenseInfo();541 return { ok: true, data: licenseInfo };542 }543 case "GET_LICENSE_KEY": {544 const key = await getLicenseKey();545 return { ok: true, data: key };546 }547 case "DEACTIVATE_LICENSE": {548 await deactivateLicense();549 return { ok: true };550 }551 case "SHOULD_PLAY_CELEBRATION": {552 const should = await shouldPlayCelebration();553 return { ok: true, data: should };554 }555 case "MARK_CELEBRATION_PLAYED": {556 await markCelebrationPlayed();557 return { ok: true };558 }559 case "OPEN_PRO_GATE": {560 561 const { feature } = (msg.payload ?? {}) as { feature?: string };562 if (chrome.storage.session) {563 await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });564 }565 try { await chrome.action.openPopup(); } catch { }566 return { ok: true };567 }568 case "GET_IDENTITIES": {569 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };570 return { ok: true, data: vault.getIdentities() };571 }572 case "GET_ADVANCED_HEALTH_REPORT": {573 const gate = await requirePro("advanced_health");574 if (!gate.allowed) return { ok: false, error: "pro_required" };575 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };576 const report = await buildAdvancedHealthReport(vault.getEntries());577 return { ok: true, data: report };578 }579 case "INITIATE_PASSWORD_CHANGE": {580 const gate = await requirePro("smart_password_change");581 if (!gate.allowed) return { ok: false, error: "pro_required" };582 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };583 const { id } = msg.payload as { id: string };584 const entry = vault.getEntryById(id);585 if (!entry) return { ok: false, error: "entry not found" };586 const url = resolveResetUrl(entry);587 if (!url) return { ok: false, error: "no reset url" };588 try {589 await chrome.tabs.create({ url });590 return { ok: true, data: { url } };591 } catch (err) {592 return {593 ok: false,594 error: err instanceof Error ? err.message : "failed to open tab",595 };596 }597 }598 case "IS_DURESS_ENABLED": {599 const enabled = await vault.isDuressEnabled();600 return { ok: true, data: enabled };601 }602 case "SETUP_DURESS": {603 const { duressPassword } = msg.payload as { duressPassword: string };604 await vault.setupDuress(duressPassword);605 return { ok: true };606 }607 case "DISABLE_DURESS": {608 await vault.disableDuress();609 return { ok: true };610 }611 case "VERIFY_MAIN_PASSWORD": {612 const { password } = msg.payload as { password: string };613 const ok = await vault.verifyMainPassword(password);614 return { ok: true, data: ok };615 }616 case "CHANGE_PASSWORD": {617 const { currentPassword, newPassword } = msg.payload as {618 currentPassword: string;619 newPassword: string;620 };621 const changed = await vault.changePassword(currentPassword, newPassword);622 return changed623 ? { ok: true }624 : { ok: false, error: "incorrect current password" };625 }626 case "GET_SITE_PASSWORD_POLICY": {627 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });628 if (!tab?.id) return { ok: true, data: null };629 try {630 const response = await chrome.tabs.sendMessage(tab.id, {631 type: "GET_SITE_PASSWORD_POLICY",632 });633 return (response as MessageResponse) ?? { ok: true, data: null };634 } catch {635 return { ok: true, data: null };636 }637 }638 case "GET_SYNC_STATE": {639 const { getPairedDevices, getTrustedNetworks } = await import('./sync-storage');640 const { isOnTailscale } = await import('./sync-network');641 const { isProUser } = await import('./license');642 const { storageGet } = await import('../shared/storage');643 const pairedDevices = await getPairedDevices();644 const trustedNetworks = await getTrustedNetworks();645 const tailscale = await isOnTailscale();646 const pro = await isProUser();647648 let status: SyncStatus = "offline";649 if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {650 if (pairedDevices.length === 0) {651 status = "no_peers";652 } else {653 status = "synced";654 }655 } else {656 status = "not_trusted";657 }658659 const lastSyncRaw = await storageGet("peach_last_sync_at");660 const lastSyncParsed = lastSyncRaw ? Number.parseInt(lastSyncRaw, 10) : NaN;661 const lastSyncAt = Number.isFinite(lastSyncParsed) ? lastSyncParsed : undefined;662663 return { ok: true, data: { pairedDevices, trustedNetworks, status, lastSyncAt, isPro: pro } };664 }665 case "TRIGGER_SYNC": {666 const gate = await requirePro("sync");667 if (!gate.allowed) return { ok: false, error: "pro_required" };668 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };669 return { ok: false, error: "no_native_host" };670 }671 case "START_PAIRING": {672 const gate = await requirePro("sync");673 if (!gate.allowed) return { ok: false, error: "pro_required" };674 return { ok: true };675 }676 case "CANCEL_PAIRING": {677 return { ok: true };678 }679 case "ACCEPT_PAIRING": {680 const gate = await requirePro("sync");681 if (!gate.allowed) return { ok: false, error: "pro_required" };682 return { ok: true };683 }684 case "REMOVE_PAIRED_DEVICE": {685 const { deviceId } = msg.payload as { deviceId: string };686 const { removePairedDevice } = await import('./sync-storage');687 await removePairedDevice(deviceId);688 return { ok: true };689 }690 case "REMOVE_TRUSTED_NETWORK": {691 const { ssid, bssid, isTailscale } = msg.payload as any;692 const { removeTrustedNetwork, getTrustedNetworks } = await import('./sync-storage');693 const networks = await getTrustedNetworks();694 const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 695 (isTailscale ? n.isTailscale : n.ssid === ssid) && 696 (!bssid || n.bssid === bssid)697 );698 if (network) {699 await removeTrustedNetwork(network.addedAt);700 }701 return { ok: true };702 }703 case "MARK_NETWORK_TRUSTED": {704 const { addTrustedNetwork } = await import('./sync-storage');705 const { getCurrentNetwork } = await import('./sync-network');706 const network = await getCurrentNetwork();707 await addTrustedNetwork({708 ssid: network.ssid,709 bssid: network.bssid,710 isTailscale: network.isTailscale,711 addedAt: Date.now(),712 });713 return { ok: true };714 }715 case "GET_INCOMING_PAIRING_REQUEST": {716 const { getIncomingPairingRequest } = await import('./sync-storage');717 const request = await getIncomingPairingRequest();718 return { ok: true, data: request };719 }720 case "RESPOND_INCOMING_PAIRING": {721 const { accept } = msg.payload as { accept: boolean };722 const { getIncomingPairingRequest, clearIncomingPairingRequest, addPairedDevice } =723 await import('./sync-storage');724 const request = await getIncomingPairingRequest();725 if (!request) return { ok: false, error: "no_pending_request" };726 if (Date.now() > request.expiresAt) {727 await clearIncomingPairingRequest();728 return { ok: false, error: "expired" };729 }730 await clearIncomingPairingRequest();731 if (accept) {732 await addPairedDevice({733 deviceId: request.publicKeyHex,734 displayName: request.deviceName,735 x25519PublicKey: request.publicKeyHex,736 pairedAt: Date.now(),737 lastSeenAt: Date.now(),738 });739 }740 return { ok: true, data: { accepted: accept, deviceName: request.deviceName } };741 }742 case "START_PEER_DISCOVERY": {743 const gate = await requirePro("sync");744 if (!gate.allowed) return { ok: false, error: "pro_required" };745 return { ok: false, error: "no_native_host" };746 }747 case "STOP_PEER_DISCOVERY": {748 return { ok: true };749 }750 case "START_PAIRING_SERVER": {751 const gate = await requirePro("sync");752 if (!gate.allowed) return { ok: false, error: "pro_required" };753 const token = generateSixDigitToken();754 const expiresAt = Date.now() + PAIRING_TOKEN_TTL_MS;755 const { setActivePairingToken } = await import('./pairing-server-state');756 await setActivePairingToken({ token, expiresAt });757 return { ok: true, data: { token, expiresAt } };758 }759 case "STOP_PAIRING_SERVER": {760 try {761 const { setActivePairingToken } = await import("./pairing-server-state");762 await setActivePairingToken(null);763 } catch { }764 return { ok: true };765 }766 case "CONNECT_WITH_CODE": {767 const gate = await requirePro("sync");768 if (!gate.allowed) return { ok: false, error: "pro_required" };769 return { ok: false, error: "no_native_host" };770 }771 case "GET_RECENT_PAIRING": {772 const { consumeRecentPairing } = await import('./pairing-server-state');773 const recent = await consumeRecentPairing();774 return { ok: true, data: recent };775 }776 case "INITIATE_PAIR_WITH_PEER": {777 const gate = await requirePro("sync");778 if (!gate.allowed) return { ok: false, error: "pro_required" };779 return { ok: false, error: "no_native_host" };780 }781 case "DEBUG_INJECT_INCOMING_PAIRING": {782 783 784 785 786 const { deviceName, publicKeyHex } = (msg.payload ?? {}) as {787 deviceName?: string;788 publicKeyHex?: string;789 };790 if (!deviceName || !publicKeyHex) {791 return { ok: false, error: "missing_payload" };792 }793 const { setIncomingPairingRequest } = await import('./sync-storage');794 const now = Date.now();795 await setIncomingPairingRequest({796 deviceName,797 publicKeyHex,798 receivedAt: now,799 expiresAt: now + 60_000,800 });801 return { ok: true };802 }803 case "RESET_SYNC_IDENTITY": {804 const gate = await requirePro("sync");805 if (!gate.allowed) return { ok: false, error: "pro_required" };806 const { getPairedDevices, removePairedDevice } = await import('./sync-storage');807 const devices = await getPairedDevices();808 for (const device of devices) {809 await removePairedDevice(device.deviceId);810 }811 return { ok: true, data: { removedDevices: devices.length, nativePublicKey: null } };812 }813 case "RECORD_MANUAL_SUMMON": {814 const { domain } = msg.payload as { domain: string };815 const settings = vault.getSettings();816 const existing = settings.manualSummonDomains?.[domain];817 const now = Date.now();818 await vault.updateSettings({819 manualSummonDomains: {820 ...settings.manualSummonDomains,821 [domain]: {822 firstSummonedAt: existing?.firstSummonedAt ?? now,823 summonCount: (existing?.summonCount ?? 0) + 1,824 lastSummonedAt: now,825 },826 },827 });828 return { ok: true };829 }830 case "DECLINE_CARD_SAVE": {831 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };832 await vault.declineCardSaveOffer(last4, expMonth, expYear);833 return { ok: true };834 }835 case "IS_CARD_DECLINED": {836 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };837 const declined = await vault.isCardDeclined(last4, expMonth, expYear);838 return { ok: true, data: declined };839 }840 case "INITIATE_FIREFOX_PASSKEY_CREATE": {841 const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {842 rpId?: string;843 rpName?: string;844 userName?: string;845 userDisplayName?: string;846 };847 if (!rpId || !userName) return { ok: false, error: "rpId and userName required" };848 return await initiateFirefoxPasskeyCreate({849 rpId,850 rpName: rpName ?? rpId,851 userName,852 userDisplayName,853 });854 }855856 case "COMPLETE_FIREFOX_PASSKEY_CREATE": {857 const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;858 if (!payload) return { ok: false, error: "missing payload" };859 return await completeFirefoxPasskeyCreate(payload);860 }861862 case "PASSKEY_OFFER_DECLINE": {863 const { domain } = (msg.payload ?? {}) as { domain?: string };864 if (!domain) return { ok: false, error: "domain required" };865 await recordPasskeyOfferDecline(domain);866 return { ok: true };867 }868869 case "GET_PASSKEY_OFFER_DECLINED": {870 const { domain } = (msg.payload ?? {}) as { domain?: string };871 if (!domain) return { ok: false, error: "domain required" };872 return { ok: true, data: await isPasskeyOfferDeclined(domain) };873 }874875 case "PASSKEY_HAS_CREDENTIALS": {876 const payload = msg.payload as {877 rpId?: string;878 allowCredentials?: Array<{ id: string; type?: string }>;879 } | undefined;880 if (!payload?.rpId) return { ok: false, error: "rpId required" };881 return await handlePasskeyHasCredentials({882 rpId: payload.rpId,883 allowCredentials: payload.allowCredentials ?? [],884 });885 }886887 case "PASSKEY_CREATE": {888 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;889 if (!payload?.rpId || !payload?.userName) {890 return { ok: false, error: "rpId and userName required" };891 }892 return await handleFirefoxPasskeyCreate(payload);893 }894895 case "PASSKEY_GET": {896 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;897 if (!payload?.rpId) return { ok: false, error: "rpId required" };898 return await handleFirefoxPasskeyGet(payload);899 }900901 case "GET_PASSKEY_FOR_DOMAIN": {902 const { domain } = (msg.payload ?? {}) as { domain?: string };903 if (!domain) return { ok: false, error: "domain required" };904 if (!vault.isUnlocked()) return { ok: true, data: null };905 const match = vault906 .getEntries()907 .find((entry) => entry.passkey?.rpId === domain);908 if (!match || !match.passkey) return { ok: true, data: null };909 910 911 return {912 ok: true,913 data: {914 rpId: match.passkey.rpId,915 userName: match.passkey.userName,916 browser: match.passkey.browser,917 },918 };919 }920921 case "DESTROY_VAULT": {922 923 924 925 926 927 vault.lock();928 await storageClear();929 await vault.clearVaultHistory();930 if (chrome.storage.session) {931 await chrome.storage.session.clear();932 }933 return { ok: true };934 }935936 case "SUBMIT_FEEDBACK": {937 const { category, message, version, browser } = msg.payload as {938 category: string;939 message: string;940 version: string;941 browser: string;942 };943 if (!message.trim()) return { ok: false, error: "message is empty" };944 if (!FEEDBACK_ENDPOINT) return { ok: true };945 if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {946 return { ok: false, error: "invalid feedback endpoint" };947 }948 try {949 const res = await fetch(FEEDBACK_ENDPOINT, {950 method: "POST",951 headers: { "Content-Type": "application/json" },952 body: JSON.stringify({ category, message, version, browser, source: "peach-extension" }),953 });954 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };955 } catch (err) {956 return { ok: false, error: err instanceof Error ? err.message : "network error" };957 }958 }959 case "TRIGGER_SYNC": {960 if (isNativePlatform()) {961 bridge?.ensureServerRunning?.();962 return { ok: true, data: { syncedCount: 0, mode: "server" as const } };963 }964 return await desktopTriggerSync({ getLastKnownIp: () => null });965 }966 967 case "UNLOCK_WITH_KEY": {968 const { masterKeyHex } = msg.payload as { masterKeyHex: string };969 if (!isNativePlatform()) {970 return { ok: false, error: "desktop_only_uses_password" };971 }972 try {973 const result = await vault.unlockWithKey(masterKeyHex);974 if (result.ok) await publishInMemoryVault(result.entries);975 return { ok: result.ok, data: result.ok ? { entries: result.entries } : undefined, error: result.ok ? undefined : result.error };976 } catch {977 return { ok: false, error: "invalid_key" };978 }979 }980 981 case "GET_DERIVED_KEY": {982 if (!isNativePlatform()) {983 return { ok: false, error: "android_only" };984 }985 if (!vault.isUnlocked()) {986 return { ok: false, error: "vault_locked" };987 }988 return { ok: true, data: { masterKeyHex: vault.getDerivedKeyHex() } };989 }990 991 case "SCAN_PAIRING_QR": {992 if (!isNativePlatform()) {993 return { ok: false, error: "android_only" };994 }995 const { token } = msg.payload as { token: string };996 bridge?.startPairingSession?.({ token });997 return { ok: true };998 }999 1000 case "GET_LOCAL_IP": {1001 if (!isNativePlatform()) {1002 return { ok: false, error: "android_only" };1003 }1004 const { ip } = (await bridge?.getLocalIp?.()) ?? { ip: "" };1005 return { ok: true, data: { ip } };1006 }1007 1008 case "START_PAIRING_SERVER": {1009 if (isNativePlatform()) {1010 return { ok: false, error: "desktop_only" };1011 }1012 const arr = new Uint32Array(1);1013 crypto.getRandomValues(arr);1014 const token = String(100000 + (arr[0] % 900000));1015 activeDesktopPairingToken = token;1016 activeDesktopPairingExpiry = Date.now() + 5 * 60 * 1000;1017 startSubnetScan(token, { getLocalAddress: () => null });1018 return { ok: true, data: { token, expiresIn: 300 } };1019 }1020 case "GET_IDENTITY_SUGGESTIONS": {1021 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1022 const entries = vault.getEntries();1023 const suggestions = buildIdentitySuggestions(entries);1024 return { ok: true, data: { suggestions } };1025 }1026 case "POPULATE_INLINE_VAULT": {1027 if (!isNativePlatform()) return { ok: false };1028 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1029 const entries = vault.getEntries();1030 await publishInMemoryVault(entries);1031 return { ok: true };1032 }1033 default:1034 return { ok: false, error: "unknown message type" };1035 }1036 } catch (err) {1037 return {1038 ok: false,1039 error: err instanceof Error ? err.message : "operation failed",1040 };1041 }1042}10431044104510461047async function desktopTriggerSync(deps: {1048 getLastKnownIp: () => string | null;1049}): Promise<{ ok: true; syncedCount: number } | { ok: false; error: string }> {1050 const ip = deps.getLastKnownIp();1051 if (!ip) return { ok: false, error: "no_paired_devices" };1052 return { ok: true, syncedCount: 0 };1053}105410551056function startSubnetScan(1057 token: string,1058 _deps: {1059 getLocalAddress: () => string | null;1060 }1061): void {1062 1063}106410651066async function publishInMemoryVault(entries: VaultEntry[]): Promise<void> {1067 1068}106910701071function buildIdentitySuggestions(1072 entries: VaultEntry[],1073): Array<{ email: string; username: string; count: number }> {1074 const map = new Map<string, number>();1075 for (const entry of entries) {1076 if (entry.username) map.set(entry.username, (map.get(entry.username) ?? 0) + 1);1077 }1078 return Array.from(map.entries())1079 .sort((a, b) => b[1] - a[1])1080 .map(([username, count]) => ({ email: username, username, count }));1081}1082108310841085const bridge = (typeof (globalThis as any).Capacitor !== "undefined")1086 ? (globalThis as any).Capacitor.Plugins.SyncPlugin1087 : null;108810891090const isNativePlatform = (): boolean => typeof (globalThis as any).Capacitor !== "undefined" && (globalThis as any).isNativePlatform();10911092let activeDesktopPairingToken: string | null = null;1093let activeDesktopPairingExpiry = 0;109410951096function broadcastToContentScripts(message: { type: string }): void {1097 if (!chrome.tabs?.query) return;1098 chrome.tabs.query({}, (tabs) => {1099 for (const tab of tabs) {1100 if (tab.id !== undefined) {1101 chrome.tabs.sendMessage(tab.id, message).catch(() => {1102 1103 });1104 }1105 }1106 });1107}11081109function shouldRefreshAutoLock(type: MessageType): boolean {1110 return (1111 type !== "HAS_VAULT" &&1112 type !== "IS_UNLOCKED" &&1113 type !== "GET_VAULT_STATUS" &&1114 type !== "LOCK" &&1115 type !== "UPDATE_SETTINGS" &&1116 type !== "GET_PENDING_PASSKEY" &&1117 type !== "GET_PASSKEY_PROVIDER_STATE" &&1118 type !== "DECLINE_CARD_SAVE" &&1119 type !== "IS_CARD_DECLINED" &&1120 type !== "SUBMIT_FEEDBACK"1121 );1122}11231124function isAllowedCanaryWebhookUrl(value: string): boolean {1125 try {1126 const url = new URL(value);1127 if (url.protocol !== "https:") return false;11281129 const hostname = url.hostname.toLowerCase();1130 if (1131 hostname === "localhost" ||1132 hostname.endsWith(".localhost") ||1133 isPrivateIpv4(hostname) ||1134 isPrivateIpv6(hostname)1135 ) {1136 return false;1137 }11381139 return true;1140 } catch {1141 return false;1142 }1143}11441145function isPrivateIpv4(hostname: string): boolean {1146 if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {1147 return true;1148 }11491150 const octets = hostname.split(".").map((part) => Number(part));1151 if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {1152 return false;1153 }11541155 return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;1156}11571158function isPrivateIpv6(hostname: string): boolean {1159 const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();1160 return (1161 normalized === "::1" ||1162 normalized.startsWith("fc") ||1163 normalized.startsWith("fd") ||1164 normalized.startsWith("fe80:")1165 );1166}