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 "LOCK":195 vault.lock();196 return { ok: true };197 case "IS_UNLOCKED":198 return { ok: true, data: vault.isUnlocked() };199 case "GET_VAULT_STATUS":200 return { ok: true, unlocked: vault.isUnlocked() };201 case "HAS_VAULT":202 return { ok: true, data: await vault.hasVault() };203 case "GET_ENTRIES":204 return { ok: true, data: vault.getEntries() };205 case "ADD_ENTRY": {206 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };207 const entry = await vault.addEntry(msg.payload as any);208 return { ok: true, data: entry };209 }210 case "BATCH_ADD_ENTRIES": {211 const { entries } = msg.payload as { entries: any[] };212 const added = await vault.batchAddEntries(entries);213 return { ok: true, data: added };214 }215 case "RESTORE_ENTRIES": {216 const { entries, mode, settings } = msg.payload as {217 entries: any[];218 mode: "replace" | "merge";219 settings?: vault.VaultSettings;220 };221 const summary = await vault.restoreEntries(entries, mode);222 if (settings && typeof settings === "object") {223 await vault.updateSettings(settings);224 }225 return { ok: true, data: summary };226 }227 case "CLEAR_VAULT_ENTRIES": {228 await vault.clearVaultEntries("REPLACE_ALL");229 return { ok: true };230 }231 case "GET_VAULT_HISTORY": {232 const snapshots = await vault.getVaultHistory();233 return { ok: true, data: snapshots };234 }235 case "RESTORE_VAULT_SNAPSHOT": {236 const { snapshotId } = msg.payload as { snapshotId: string };237 const result = await vault.restoreVaultSnapshot(snapshotId);238 if (!result.ok) return { ok: false, error: result.error };239 broadcastToContentScripts({ type: "VAULT_LOCKED" });240 return { ok: true };241 }242 case "COUNT_DUPLICATES": {243 return { ok: true, data: vault.countDuplicates() };244 }245 case "DEDUPLICATE_ENTRIES": {246 const removed = await vault.deduplicateEntries();247 return { ok: true, data: removed };248 }249 case "UPDATE_ENTRY": {250 const { id, patch } = msg.payload as any;251 await vault.updateEntry(id, patch);252 return { ok: true };253 }254 case "ADD_ADDITIONAL_URL": {255 const { id, url } = msg.payload as { id: string; url: string };256 const trimmed = typeof url === "string" ? url.trim() : "";257 if (!trimmed) return { ok: false, error: "url required" };258 const existing = vault.getEntryById(id);259 if (!existing) return { ok: false, error: "entry not found" };260 if (existing.type !== "login") return { ok: false, error: "login entries only" };261 const merged = [...(existing.additionalUrls ?? []), trimmed];262 await vault.updateEntry(id, { additionalUrls: merged });263 return { ok: true };264 }265 case "DELETE_ENTRY": {266 const { id } = msg.payload as { id: string };267 await vault.deleteEntry(id);268 return { ok: true };269 }270 case "GET_DELETED_ENTRIES":271 return { ok: true, data: vault.getDeletedEntries() };272 case "RESTORE_ENTRY": {273 const { id } = msg.payload as { id: string };274 await vault.restoreEntry(id);275 return { ok: true };276 }277 case "PURGE_ENTRY": {278 const { id } = msg.payload as { id: string };279 await vault.purgeEntry(id);280 return { ok: true };281 }282 case "PURGE_EXPIRED_TRASH": {283 const { retentionMs } = msg.payload as { retentionMs: number };284 const purged = await vault.purgeExpiredTrash(retentionMs);285 return { ok: true, data: purged };286 }287 case "AUTOFILL_QUERY": {288 if (!vault.isUnlocked()) {289 return { ok: false, error: "VAULT_LOCKED" };290 }291 const { domain, includeAll, kind } = msg.payload as {292 domain: string;293 includeAll?: boolean;294 kind?: "login" | "card";295 };296 297 298 299 300 if (kind === "card") {301 const cards = vault.getEntries().filter((e) => e.type === "card");302 return {303 ok: true,304 data: cards.map((e) => ({305 id: e.id,306 title: e.cardNickname || e.title,307 cardholderName: e.cardholderName,308 last4: e.last4,309 expMonth: e.expMonth,310 expYear: e.expYear,311 network: e.network,312 })),313 };314 }315 316 317 318 319 320 const matches = (321 includeAll322 ? vault323 .getEntries()324 .filter((entry) => entry.type !== "note" && entry.type !== "card")325 : vault.queryDomain(domain)326 ).filter((entry) => !isPasskeyOnly(entry));327 return {328 ok: true,329 data: matches.map((e) =>330 includeAll331 ? {332 id: e.id,333 title: e.title,334 hasPasskey: e.passkey !== undefined,335 }336 : {337 id: e.id,338 title: e.title,339 username: e.username,340 url: e.url,341 hasTotp: Boolean(e.totp),342 hasPasskey: e.passkey !== undefined,343 }344 ),345 };346 }347 case "GET_ENTRY_FOR_FILL": {348 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };349 const { id } = msg.payload as { id: string };350 const entry = vault.getEntryById(id);351 if (!entry) return { ok: false, error: "entry not found" };352 353 354 355 if (isPasskeyOnly(entry)) return { ok: false, error: "passkey not fillable" };356 357 358 359 const { passkey: _strippedPasskey, ...safeEntry } = entry;360 return { ok: true, data: safeEntry };361 }362 case "COPY": {363 const { text, label, alreadyWritten } = msg.payload as {364 text: string;365 label: string;366 alreadyWritten?: boolean;367 };368 await copyToClipboard(text, label, Boolean(alreadyWritten));369 return { ok: true };370 }371 case "CHECK_BREACH": {372 const { password } = msg.payload as { password: string };373 const count = await vault.checkBreach(password);374 return { ok: true, data: count };375 }376 case "CHECK_ALL_BREACHES": {377 const allEntries = vault.getEntries();378 const results: Record<string, number> = {};379 for (const entry of allEntries) {380 if (entry.type === "note") continue;381 if (!entry.password) continue;382 results[entry.id] = await vault.checkBreach(entry.password);383 384 await new Promise((r) => setTimeout(r, 200));385 }386 387 for (const [id, count] of Object.entries(results)) {388 const entry = vault.getEntryById(id);389 if (entry && (entry.breach ?? 0) !== count) {390 await vault.updateEntry(id, { breach: count > 0 ? count : undefined });391 }392 }393 return { ok: true, data: results };394 }395 case "GET_SETTINGS":396 return { ok: true, data: await vault.ensureSettingsLoaded() };397 case "UPDATE_SETTINGS": {398 const patch = msg.payload as Record<string, unknown>;399 await vault.updateSettings(patch as any);400 if (typeof patch.autoLockMinutes === "number") {401 await setupAutoLock();402 if (vault.isUnlocked()) touchAutoLock();403 }404 return { ok: true };405 }406 case "GET_PENDING_PASSKEY":407 return { ok: true, data: await getPendingPasskeyRequest() };408 case "APPROVE_PASSKEY": {409 const { requestId } = msg.payload as { requestId: number };410 await resolvePendingPasskeyRequest(requestId, true);411 return { ok: true };412 }413 case "DENY_PASSKEY": {414 const { requestId } = msg.payload as { requestId: number };415 await resolvePendingPasskeyRequest(requestId, false);416 return { ok: true };417 }418 case "GET_PASSKEYS":419 return {420 ok: true,421 data: vault.getEntries().filter((entry) => entry.passkey !== undefined),422 };423 case "DELETE_PASSKEY": {424 const { id } = msg.payload as { id: string };425 await vault.deleteEntry(id);426 return { ok: true };427 }428 case "GET_PASSKEY_PROVIDER_STATE":429 return { ok: true, data: await getPasskeyProviderState() };430 case "SET_PASSKEY_PROVIDER_ENABLED": {431 const { enabled } = msg.payload as { enabled: boolean };432 433 434 if (enabled) {435 const gate = await requirePro("passkeys");436 if (!gate.allowed) return { ok: false, error: "pro_required" };437 }438 return { ok: true, data: await setPasskeyProviderEnabled(enabled) };439 }440 case "FIRE_CANARY_PING": {441 const { webhookUrl, entryTitle, trigger } = msg.payload as {442 webhookUrl: string;443 entryTitle: string;444 trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";445 };446 if (!isAllowedCanaryWebhookUrl(webhookUrl)) {447 return { ok: false, error: "invalid webhook url" };448 }449 const body = JSON.stringify({450 event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",451 entry: entryTitle,452 trigger,453 timestamp: new Date().toISOString(),454 source: "peach-extension",455 });456 if (trigger === "test") {457 458 459 460 461 462 try {463 const res = await fetch(webhookUrl, {464 method: "POST",465 headers: { "Content-Type": "application/json" },466 body,467 });468 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };469 } catch (err) {470 return { ok: false, error: err instanceof Error ? err.message : "network error" };471 }472 }473 474 fetch(webhookUrl, {475 method: "POST",476 headers: { "Content-Type": "application/json" },477 body,478 }).catch(() => {});479 return { ok: true };480 }481 case "ACTIVATE_LICENSE": {482 const { key } = msg.payload as { key: string };483 const licenseInfo = await activateLicense(key);484 return { ok: true, data: licenseInfo };485 }486 case "GET_LICENSE": {487 const licenseInfo = await getLicenseInfo();488 return { ok: true, data: licenseInfo };489 }490 case "GET_LICENSE_KEY": {491 const key = await getLicenseKey();492 return { ok: true, data: key };493 }494 case "DEACTIVATE_LICENSE": {495 await deactivateLicense();496 return { ok: true };497 }498 case "SHOULD_PLAY_CELEBRATION": {499 const should = await shouldPlayCelebration();500 return { ok: true, data: should };501 }502 case "MARK_CELEBRATION_PLAYED": {503 await markCelebrationPlayed();504 return { ok: true };505 }506 case "OPEN_PRO_GATE": {507 508 const { feature } = (msg.payload ?? {}) as { feature?: string };509 if (chrome.storage.session) {510 await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });511 }512 try { await chrome.action.openPopup(); } catch { }513 return { ok: true };514 }515 case "GET_IDENTITIES": {516 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };517 return { ok: true, data: vault.getIdentities() };518 }519 case "GET_ADVANCED_HEALTH_REPORT": {520 const gate = await requirePro("advanced_health");521 if (!gate.allowed) return { ok: false, error: "pro_required" };522 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };523 const report = await buildAdvancedHealthReport(vault.getEntries());524 return { ok: true, data: report };525 }526 case "INITIATE_PASSWORD_CHANGE": {527 const gate = await requirePro("smart_password_change");528 if (!gate.allowed) return { ok: false, error: "pro_required" };529 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };530 const { id } = msg.payload as { id: string };531 const entry = vault.getEntryById(id);532 if (!entry) return { ok: false, error: "entry not found" };533 const url = resolveResetUrl(entry);534 if (!url) return { ok: false, error: "no reset url" };535 try {536 await chrome.tabs.create({ url });537 return { ok: true, data: { url } };538 } catch (err) {539 return {540 ok: false,541 error: err instanceof Error ? err.message : "failed to open tab",542 };543 }544 }545 case "IS_DURESS_ENABLED": {546 const enabled = await vault.isDuressEnabled();547 return { ok: true, data: enabled };548 }549 case "SETUP_DURESS": {550 const { duressPassword } = msg.payload as { duressPassword: string };551 await vault.setupDuress(duressPassword);552 return { ok: true };553 }554 case "DISABLE_DURESS": {555 await vault.disableDuress();556 return { ok: true };557 }558 case "VERIFY_MAIN_PASSWORD": {559 const { password } = msg.payload as { password: string };560 const ok = await vault.verifyMainPassword(password);561 return { ok: true, data: ok };562 }563 case "CHANGE_PASSWORD": {564 const { currentPassword, newPassword } = msg.payload as {565 currentPassword: string;566 newPassword: string;567 };568 const changed = await vault.changePassword(currentPassword, newPassword);569 return changed570 ? { ok: true }571 : { ok: false, error: "incorrect current password" };572 }573 case "GET_SITE_PASSWORD_POLICY": {574 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });575 if (!tab?.id) return { ok: true, data: null };576 try {577 const response = await chrome.tabs.sendMessage(tab.id, {578 type: "GET_SITE_PASSWORD_POLICY",579 });580 return (response as MessageResponse) ?? { ok: true, data: null };581 } catch {582 return { ok: true, data: null };583 }584 }585 case "GET_SYNC_STATE": {586 const { getPairedDevices, getTrustedNetworks } = await import('./sync-storage');587 const { isOnTailscale } = await import('./sync-network');588 const { isProUser } = await import('./license');589 const { storageGet } = await import('../shared/storage');590 const pairedDevices = await getPairedDevices();591 const trustedNetworks = await getTrustedNetworks();592 const tailscale = await isOnTailscale();593 const pro = await isProUser();594595 let status: SyncStatus = "offline";596 if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {597 if (pairedDevices.length === 0) {598 status = "no_peers";599 } else {600 status = "synced";601 }602 } else {603 status = "not_trusted";604 }605606 const lastSyncRaw = await storageGet("peach_last_sync_at");607 const lastSyncParsed = lastSyncRaw ? Number.parseInt(lastSyncRaw, 10) : NaN;608 const lastSyncAt = Number.isFinite(lastSyncParsed) ? lastSyncParsed : undefined;609610 return { ok: true, data: { pairedDevices, trustedNetworks, status, lastSyncAt, isPro: pro } };611 }612 case "TRIGGER_SYNC": {613 const gate = await requirePro("sync");614 if (!gate.allowed) return { ok: false, error: "pro_required" };615 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };616 return { ok: false, error: "no_native_host" };617 }618 case "START_PAIRING": {619 const gate = await requirePro("sync");620 if (!gate.allowed) return { ok: false, error: "pro_required" };621 return { ok: true };622 }623 case "CANCEL_PAIRING": {624 return { ok: true };625 }626 case "ACCEPT_PAIRING": {627 const gate = await requirePro("sync");628 if (!gate.allowed) return { ok: false, error: "pro_required" };629 return { ok: true };630 }631 case "REMOVE_PAIRED_DEVICE": {632 const { deviceId } = msg.payload as { deviceId: string };633 const { removePairedDevice } = await import('./sync-storage');634 await removePairedDevice(deviceId);635 return { ok: true };636 }637 case "REMOVE_TRUSTED_NETWORK": {638 const { ssid, bssid, isTailscale } = msg.payload as any;639 const { removeTrustedNetwork, getTrustedNetworks } = await import('./sync-storage');640 const networks = await getTrustedNetworks();641 const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 642 (isTailscale ? n.isTailscale : n.ssid === ssid) && 643 (!bssid || n.bssid === bssid)644 );645 if (network) {646 await removeTrustedNetwork(network.addedAt);647 }648 return { ok: true };649 }650 case "MARK_NETWORK_TRUSTED": {651 const { addTrustedNetwork } = await import('./sync-storage');652 const { getCurrentNetwork } = await import('./sync-network');653 const network = await getCurrentNetwork();654 await addTrustedNetwork({655 ssid: network.ssid,656 bssid: network.bssid,657 isTailscale: network.isTailscale,658 addedAt: Date.now(),659 });660 return { ok: true };661 }662 case "GET_INCOMING_PAIRING_REQUEST": {663 const { getIncomingPairingRequest } = await import('./sync-storage');664 const request = await getIncomingPairingRequest();665 return { ok: true, data: request };666 }667 case "RESPOND_INCOMING_PAIRING": {668 const { accept } = msg.payload as { accept: boolean };669 const { getIncomingPairingRequest, clearIncomingPairingRequest, addPairedDevice } =670 await import('./sync-storage');671 const request = await getIncomingPairingRequest();672 if (!request) return { ok: false, error: "no_pending_request" };673 if (Date.now() > request.expiresAt) {674 await clearIncomingPairingRequest();675 return { ok: false, error: "expired" };676 }677 await clearIncomingPairingRequest();678 if (accept) {679 await addPairedDevice({680 deviceId: request.publicKeyHex,681 displayName: request.deviceName,682 x25519PublicKey: request.publicKeyHex,683 pairedAt: Date.now(),684 lastSeenAt: Date.now(),685 });686 }687 return { ok: true, data: { accepted: accept, deviceName: request.deviceName } };688 }689 case "START_PEER_DISCOVERY": {690 const gate = await requirePro("sync");691 if (!gate.allowed) return { ok: false, error: "pro_required" };692 return { ok: false, error: "no_native_host" };693 }694 case "STOP_PEER_DISCOVERY": {695 return { ok: true };696 }697 case "START_PAIRING_SERVER": {698 const gate = await requirePro("sync");699 if (!gate.allowed) return { ok: false, error: "pro_required" };700 const token = generateSixDigitToken();701 const expiresAt = Date.now() + PAIRING_TOKEN_TTL_MS;702 const { setActivePairingToken } = await import('./pairing-server-state');703 await setActivePairingToken({ token, expiresAt });704 return { ok: true, data: { token, expiresAt } };705 }706 case "STOP_PAIRING_SERVER": {707 try {708 const { setActivePairingToken } = await import("./pairing-server-state");709 await setActivePairingToken(null);710 } catch { }711 return { ok: true };712 }713 case "CONNECT_WITH_CODE": {714 const gate = await requirePro("sync");715 if (!gate.allowed) return { ok: false, error: "pro_required" };716 return { ok: false, error: "no_native_host" };717 }718 case "GET_RECENT_PAIRING": {719 const { consumeRecentPairing } = await import('./pairing-server-state');720 const recent = await consumeRecentPairing();721 return { ok: true, data: recent };722 }723 case "INITIATE_PAIR_WITH_PEER": {724 const gate = await requirePro("sync");725 if (!gate.allowed) return { ok: false, error: "pro_required" };726 return { ok: false, error: "no_native_host" };727 }728 case "DEBUG_INJECT_INCOMING_PAIRING": {729 730 731 732 733 const { deviceName, publicKeyHex } = (msg.payload ?? {}) as {734 deviceName?: string;735 publicKeyHex?: string;736 };737 if (!deviceName || !publicKeyHex) {738 return { ok: false, error: "missing_payload" };739 }740 const { setIncomingPairingRequest } = await import('./sync-storage');741 const now = Date.now();742 await setIncomingPairingRequest({743 deviceName,744 publicKeyHex,745 receivedAt: now,746 expiresAt: now + 60_000,747 });748 return { ok: true };749 }750 case "RESET_SYNC_IDENTITY": {751 const gate = await requirePro("sync");752 if (!gate.allowed) return { ok: false, error: "pro_required" };753 const { getPairedDevices, removePairedDevice } = await import('./sync-storage');754 const devices = await getPairedDevices();755 for (const device of devices) {756 await removePairedDevice(device.deviceId);757 }758 return { ok: true, data: { removedDevices: devices.length, nativePublicKey: null } };759 }760 case "RECORD_MANUAL_SUMMON": {761 const { domain } = msg.payload as { domain: string };762 const settings = vault.getSettings();763 const existing = settings.manualSummonDomains?.[domain];764 const now = Date.now();765 await vault.updateSettings({766 manualSummonDomains: {767 ...settings.manualSummonDomains,768 [domain]: {769 firstSummonedAt: existing?.firstSummonedAt ?? now,770 summonCount: (existing?.summonCount ?? 0) + 1,771 lastSummonedAt: now,772 },773 },774 });775 return { ok: true };776 }777 case "DECLINE_CARD_SAVE": {778 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };779 await vault.declineCardSaveOffer(last4, expMonth, expYear);780 return { ok: true };781 }782 case "IS_CARD_DECLINED": {783 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };784 const declined = await vault.isCardDeclined(last4, expMonth, expYear);785 return { ok: true, data: declined };786 }787 case "INITIATE_FIREFOX_PASSKEY_CREATE": {788 const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {789 rpId?: string;790 rpName?: string;791 userName?: string;792 userDisplayName?: string;793 };794 if (!rpId || !userName) return { ok: false, error: "rpId and userName required" };795 return await initiateFirefoxPasskeyCreate({796 rpId,797 rpName: rpName ?? rpId,798 userName,799 userDisplayName,800 });801 }802803 case "COMPLETE_FIREFOX_PASSKEY_CREATE": {804 const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;805 if (!payload) return { ok: false, error: "missing payload" };806 return await completeFirefoxPasskeyCreate(payload);807 }808809 case "PASSKEY_OFFER_DECLINE": {810 const { domain } = (msg.payload ?? {}) as { domain?: string };811 if (!domain) return { ok: false, error: "domain required" };812 await recordPasskeyOfferDecline(domain);813 return { ok: true };814 }815816 case "GET_PASSKEY_OFFER_DECLINED": {817 const { domain } = (msg.payload ?? {}) as { domain?: string };818 if (!domain) return { ok: false, error: "domain required" };819 return { ok: true, data: await isPasskeyOfferDeclined(domain) };820 }821822 case "PASSKEY_HAS_CREDENTIALS": {823 const payload = msg.payload as {824 rpId?: string;825 allowCredentials?: Array<{ id: string; type?: string }>;826 } | undefined;827 if (!payload?.rpId) return { ok: false, error: "rpId required" };828 return await handlePasskeyHasCredentials({829 rpId: payload.rpId,830 allowCredentials: payload.allowCredentials ?? [],831 });832 }833834 case "PASSKEY_CREATE": {835 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;836 if (!payload?.rpId || !payload?.userName) {837 return { ok: false, error: "rpId and userName required" };838 }839 return await handleFirefoxPasskeyCreate(payload);840 }841842 case "PASSKEY_GET": {843 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;844 if (!payload?.rpId) return { ok: false, error: "rpId required" };845 return await handleFirefoxPasskeyGet(payload);846 }847848 case "GET_PASSKEY_FOR_DOMAIN": {849 const { domain } = (msg.payload ?? {}) as { domain?: string };850 if (!domain) return { ok: false, error: "domain required" };851 if (!vault.isUnlocked()) return { ok: true, data: null };852 const match = vault853 .getEntries()854 .find((entry) => entry.passkey?.rpId === domain);855 if (!match || !match.passkey) return { ok: true, data: null };856 857 858 return {859 ok: true,860 data: {861 rpId: match.passkey.rpId,862 userName: match.passkey.userName,863 browser: match.passkey.browser,864 },865 };866 }867868 case "DESTROY_VAULT": {869 870 871 872 873 874 vault.lock();875 await storageClear();876 await vault.clearVaultHistory();877 if (chrome.storage.session) {878 await chrome.storage.session.clear();879 }880 return { ok: true };881 }882883 case "SUBMIT_FEEDBACK": {884 const { category, message, version, browser } = msg.payload as {885 category: string;886 message: string;887 version: string;888 browser: string;889 };890 if (!message.trim()) return { ok: false, error: "message is empty" };891 if (!FEEDBACK_ENDPOINT) return { ok: true };892 if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {893 return { ok: false, error: "invalid feedback endpoint" };894 }895 try {896 const res = await fetch(FEEDBACK_ENDPOINT, {897 method: "POST",898 headers: { "Content-Type": "application/json" },899 body: JSON.stringify({ category, message, version, browser, source: "peach-extension" }),900 });901 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };902 } catch (err) {903 return { ok: false, error: err instanceof Error ? err.message : "network error" };904 }905 }906 case "TRIGGER_SYNC": {907 if (isNativePlatform()) {908 bridge?.ensureServerRunning?.();909 return { ok: true, data: { syncedCount: 0, mode: "server" as const } };910 }911 return await desktopTriggerSync({ getLastKnownIp: () => null });912 }913 914 case "UNLOCK_WITH_KEY": {915 const { masterKeyHex } = msg.payload as { masterKeyHex: string };916 if (!isNativePlatform()) {917 return { ok: false, error: "desktop_only_uses_password" };918 }919 try {920 const result = await vault.unlockWithKey(masterKeyHex);921 if (result.ok) await publishInMemoryVault(result.entries);922 return { ok: result.ok, data: result.ok ? { entries: result.entries } : undefined, error: result.ok ? undefined : result.error };923 } catch {924 return { ok: false, error: "invalid_key" };925 }926 }927 928 case "GET_DERIVED_KEY": {929 if (!isNativePlatform()) {930 return { ok: false, error: "android_only" };931 }932 if (!vault.isUnlocked()) {933 return { ok: false, error: "vault_locked" };934 }935 return { ok: true, data: { masterKeyHex: vault.getDerivedKeyHex() } };936 }937 938 case "SCAN_PAIRING_QR": {939 if (!isNativePlatform()) {940 return { ok: false, error: "android_only" };941 }942 const { token } = msg.payload as { token: string };943 bridge?.startPairingSession?.({ token });944 return { ok: true };945 }946 947 case "GET_LOCAL_IP": {948 if (!isNativePlatform()) {949 return { ok: false, error: "android_only" };950 }951 const { ip } = (await bridge?.getLocalIp?.()) ?? { ip: "" };952 return { ok: true, data: { ip } };953 }954 955 case "START_PAIRING_SERVER": {956 if (isNativePlatform()) {957 return { ok: false, error: "desktop_only" };958 }959 const arr = new Uint32Array(1);960 crypto.getRandomValues(arr);961 const token = String(100000 + (arr[0] % 900000));962 activeDesktopPairingToken = token;963 activeDesktopPairingExpiry = Date.now() + 5 * 60 * 1000;964 startSubnetScan(token, { getLocalAddress: () => null });965 return { ok: true, data: { token, expiresIn: 300 } };966 }967 case "GET_IDENTITY_SUGGESTIONS": {968 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };969 const entries = vault.getEntries();970 const suggestions = buildIdentitySuggestions(entries);971 return { ok: true, data: { suggestions } };972 }973 case "POPULATE_INLINE_VAULT": {974 if (!isNativePlatform()) return { ok: false };975 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };976 const entries = vault.getEntries();977 await publishInMemoryVault(entries);978 return { ok: true };979 }980 default:981 return { ok: false, error: "unknown message type" };982 }983 } catch (err) {984 return {985 ok: false,986 error: err instanceof Error ? err.message : "operation failed",987 };988 }989}990991992993994async function desktopTriggerSync(deps: {995 getLastKnownIp: () => string | null;996}): Promise<{ ok: true; syncedCount: number } | { ok: false; error: string }> {997 const ip = deps.getLastKnownIp();998 if (!ip) return { ok: false, error: "no_paired_devices" };999 return { ok: true, syncedCount: 0 };1000}100110021003function startSubnetScan(1004 token: string,1005 _deps: {1006 getLocalAddress: () => string | null;1007 }1008): void {1009 1010}101110121013async function publishInMemoryVault(entries: VaultEntry[]): Promise<void> {1014 1015}101610171018function buildIdentitySuggestions(1019 entries: VaultEntry[],1020): Array<{ email: string; username: string; count: number }> {1021 const map = new Map<string, number>();1022 for (const entry of entries) {1023 if (entry.username) map.set(entry.username, (map.get(entry.username) ?? 0) + 1);1024 }1025 return Array.from(map.entries())1026 .sort((a, b) => b[1] - a[1])1027 .map(([username, count]) => ({ email: username, username, count }));1028}1029103010311032const bridge = (typeof (globalThis as any).Capacitor !== "undefined")1033 ? (globalThis as any).Capacitor.Plugins.SyncPlugin1034 : null;103510361037const isNativePlatform = (): boolean => typeof (globalThis as any).Capacitor !== "undefined" && (globalThis as any).isNativePlatform();10381039let activeDesktopPairingToken: string | null = null;1040let activeDesktopPairingExpiry = 0;104110421043function broadcastToContentScripts(message: { type: string }): void {1044 if (!chrome.tabs?.query) return;1045 chrome.tabs.query({}, (tabs) => {1046 for (const tab of tabs) {1047 if (tab.id !== undefined) {1048 chrome.tabs.sendMessage(tab.id, message).catch(() => {1049 1050 });1051 }1052 }1053 });1054}10551056function shouldRefreshAutoLock(type: MessageType): boolean {1057 return (1058 type !== "HAS_VAULT" &&1059 type !== "IS_UNLOCKED" &&1060 type !== "GET_VAULT_STATUS" &&1061 type !== "LOCK" &&1062 type !== "UPDATE_SETTINGS" &&1063 type !== "GET_PENDING_PASSKEY" &&1064 type !== "GET_PASSKEY_PROVIDER_STATE" &&1065 type !== "DECLINE_CARD_SAVE" &&1066 type !== "IS_CARD_DECLINED" &&1067 type !== "SUBMIT_FEEDBACK"1068 );1069}10701071function isAllowedCanaryWebhookUrl(value: string): boolean {1072 try {1073 const url = new URL(value);1074 if (url.protocol !== "https:") return false;10751076 const hostname = url.hostname.toLowerCase();1077 if (1078 hostname === "localhost" ||1079 hostname.endsWith(".localhost") ||1080 isPrivateIpv4(hostname) ||1081 isPrivateIpv6(hostname)1082 ) {1083 return false;1084 }10851086 return true;1087 } catch {1088 return false;1089 }1090}10911092function isPrivateIpv4(hostname: string): boolean {1093 if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {1094 return true;1095 }10961097 const octets = hostname.split(".").map((part) => Number(part));1098 if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {1099 return false;1100 }11011102 return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;1103}11041105function isPrivateIpv6(hostname: string): boolean {1106 const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();1107 return (1108 normalized === "::1" ||1109 normalized.startsWith("fc") ||1110 normalized.startsWith("fd") ||1111 normalized.startsWith("fe80:")1112 );1113}