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} from "../shared/types";46import { isPasskeyOnly } from "../shared/types";47import type { SyncStatus } from "../shared/sync-types";4849505152535455async function requirePro(56 feature: ProFeature,57 trigger: "user" | "background" = "user"58): Promise<{ allowed: boolean }> {59 if (await isProUser()) return { allowed: true };6061 if (trigger === "user") {62 63 chrome.runtime64 .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })65 .catch(() => {66 67 });68 } else {69 70 71 72 if (chrome.storage.session) {73 chrome.storage.session74 .set({ pendingProNotice: { feature, triggeredAt: Date.now() } })75 .catch(() => {});76 }77 }7879 return { allowed: false };80}818283848586const DEVICE_NAME_KEY = "peach_device_name";8788async function getLocalDeviceName(): Promise<string> {89 try {90 const stored = await chrome.storage.local.get(DEVICE_NAME_KEY);91 const value = (stored as Record<string, string | undefined>)[DEVICE_NAME_KEY];92 if (typeof value === "string" && value.trim()) return value.trim();93 } catch {94 95 }96 return "Peach";97}9899100101102const PAIRING_TOKEN_TTL_MS = 5 * 60 * 1000;103104105function generateSixDigitToken(): string {106 107 108 109 110 const buf = new Uint32Array(1);111 crypto.getRandomValues(buf);112 const value = buf[0] % 1_000_000;113 return value.toString().padStart(6, "0");114}115116117118119const PRIVATE_IPV4_RANGES = [120 { prefix: "10." },121 { prefix: "127." },122 { prefix: "169.254." },123 { prefix: "192.168." },124];125126127128129130const FEEDBACK_ENDPOINT = "";131132export function setupMessageHandler(): void {133 chrome.runtime.onMessage.addListener(134 (msg: unknown, sender, sendResponse) => {135 if (!isMessage(msg)) {136 sendResponse({ ok: false, error: "invalid message" });137 return true;138 }139 handleMessage(msg, sender)140 .then(sendResponse)141 .catch((err) =>142 sendResponse({143 ok: false,144 error: err instanceof Error ? err.message : String(err),145 })146 );147 return true;148 }149 );150}151152function isMessage(m: unknown): m is Message {153 return typeof m === "object" && m !== null && "type" in m;154}155156export async function handleMessage(157 msg: Message,158 sender: Partial<chrome.runtime.MessageSender> = {}159): Promise<MessageResponse> {160 try {161 162 163 164 165 if (166 sender.id !== undefined &&167 sender.id !== chrome.runtime.id168 ) {169 return { ok: false, error: "invalid sender" };170 }171172 if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {173 touchAutoLock();174 }175176 switch (msg.type) {177 case "CREATE_VAULT": {178 const { password } = msg.payload as { password: string };179 await vault.createVault(password);180 await setupAutoLock();181 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });182 return { ok: true };183 }184 case "UNLOCK": {185 const { password } = msg.payload as { password: string };186 const ok = await vault.unlock(password);187 if (ok) {188 await setupAutoLock();189 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });190 }191 return { ok };192 }193 case "LOCK":194 vault.lock();195 return { ok: true };196 case "IS_UNLOCKED":197 return { ok: true, data: vault.isUnlocked() };198 case "GET_VAULT_STATUS":199 return { ok: true, unlocked: vault.isUnlocked() };200 case "HAS_VAULT":201 return { ok: true, data: await vault.hasVault() };202 case "GET_ENTRIES":203 return { ok: true, data: vault.getEntries() };204 case "ADD_ENTRY": {205 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };206 const entry = await vault.addEntry(msg.payload as any);207 return { ok: true, data: entry };208 }209 case "BATCH_ADD_ENTRIES": {210 const { entries } = msg.payload as { entries: any[] };211 const added = await vault.batchAddEntries(entries);212 return { ok: true, data: added };213 }214 case "RESTORE_ENTRIES": {215 const { entries, mode, settings } = msg.payload as {216 entries: any[];217 mode: "replace" | "merge";218 settings?: vault.VaultSettings;219 };220 const summary = await vault.restoreEntries(entries, mode);221 if (settings && typeof settings === "object") {222 await vault.updateSettings(settings);223 }224 return { ok: true, data: summary };225 }226 case "CLEAR_VAULT_ENTRIES": {227 await vault.clearVaultEntries("REPLACE_ALL");228 return { ok: true };229 }230 case "GET_VAULT_HISTORY": {231 const snapshots = await vault.getVaultHistory();232 return { ok: true, data: snapshots };233 }234 case "RESTORE_VAULT_SNAPSHOT": {235 const { snapshotId } = msg.payload as { snapshotId: string };236 const result = await vault.restoreVaultSnapshot(snapshotId);237 if (!result.ok) return { ok: false, error: result.error };238 broadcastToContentScripts({ type: "VAULT_LOCKED" });239 return { ok: true };240 }241 case "COUNT_DUPLICATES": {242 return { ok: true, data: vault.countDuplicates() };243 }244 case "DEDUPLICATE_ENTRIES": {245 const removed = await vault.deduplicateEntries();246 return { ok: true, data: removed };247 }248 case "UPDATE_ENTRY": {249 const { id, patch } = msg.payload as any;250 await vault.updateEntry(id, patch);251 return { ok: true };252 }253 case "ADD_ADDITIONAL_URL": {254 const { id, url } = msg.payload as { id: string; url: string };255 const trimmed = typeof url === "string" ? url.trim() : "";256 if (!trimmed) return { ok: false, error: "url required" };257 const existing = vault.getEntryById(id);258 if (!existing) return { ok: false, error: "entry not found" };259 if (existing.type !== "login") return { ok: false, error: "login entries only" };260 const merged = [...(existing.additionalUrls ?? []), trimmed];261 await vault.updateEntry(id, { additionalUrls: merged });262 return { ok: true };263 }264 case "DELETE_ENTRY": {265 const { id } = msg.payload as { id: string };266 await vault.deleteEntry(id);267 return { ok: true };268 }269 case "GET_DELETED_ENTRIES":270 return { ok: true, data: vault.getDeletedEntries() };271 case "RESTORE_ENTRY": {272 const { id } = msg.payload as { id: string };273 await vault.restoreEntry(id);274 return { ok: true };275 }276 case "PURGE_ENTRY": {277 const { id } = msg.payload as { id: string };278 await vault.purgeEntry(id);279 return { ok: true };280 }281 case "PURGE_EXPIRED_TRASH": {282 const { retentionMs } = msg.payload as { retentionMs: number };283 const purged = await vault.purgeExpiredTrash(retentionMs);284 return { ok: true, data: purged };285 }286 case "AUTOFILL_QUERY": {287 if (!vault.isUnlocked()) {288 return { ok: false, error: "VAULT_LOCKED" };289 }290 const { domain, includeAll, kind } = msg.payload as {291 domain: string;292 includeAll?: boolean;293 kind?: "login" | "card";294 };295 296 297 298 299 if (kind === "card") {300 const cards = vault.getEntries().filter((e) => e.type === "card");301 return {302 ok: true,303 data: cards.map((e) => ({304 id: e.id,305 title: e.cardNickname || e.title,306 cardholderName: e.cardholderName,307 last4: e.last4,308 expMonth: e.expMonth,309 expYear: e.expYear,310 network: e.network,311 })),312 };313 }314 315 316 317 318 319 const matches = (320 includeAll321 ? vault322 .getEntries()323 .filter((entry) => entry.type !== "note" && entry.type !== "card")324 : vault.queryDomain(domain)325 ).filter((entry) => !isPasskeyOnly(entry));326 return {327 ok: true,328 data: matches.map((e) =>329 includeAll330 ? {331 id: e.id,332 title: e.title,333 hasPasskey: e.passkey !== undefined,334 }335 : {336 id: e.id,337 title: e.title,338 username: e.username,339 url: e.url,340 hasTotp: Boolean(e.totp),341 hasPasskey: e.passkey !== undefined,342 }343 ),344 };345 }346 case "GET_ENTRY_FOR_FILL": {347 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };348 const { id } = msg.payload as { id: string };349 const entry = vault.getEntryById(id);350 if (!entry) return { ok: false, error: "entry not found" };351 352 353 354 if (isPasskeyOnly(entry)) return { ok: false, error: "passkey not fillable" };355 356 357 358 const { passkey: _strippedPasskey, ...safeEntry } = entry;359 return { ok: true, data: safeEntry };360 }361 case "COPY": {362 const { text, label, alreadyWritten } = msg.payload as {363 text: string;364 label: string;365 alreadyWritten?: boolean;366 };367 await copyToClipboard(text, label, Boolean(alreadyWritten));368 return { ok: true };369 }370 case "CHECK_BREACH": {371 const { password } = msg.payload as { password: string };372 const count = await vault.checkBreach(password);373 return { ok: true, data: count };374 }375 case "CHECK_ALL_BREACHES": {376 const allEntries = vault.getEntries();377 const results: Record<string, number> = {};378 for (const entry of allEntries) {379 if (entry.type === "note") continue;380 if (!entry.password) continue;381 results[entry.id] = await vault.checkBreach(entry.password);382 383 await new Promise((r) => setTimeout(r, 200));384 }385 386 for (const [id, count] of Object.entries(results)) {387 const entry = vault.getEntryById(id);388 if (entry && (entry.breach ?? 0) !== count) {389 await vault.updateEntry(id, { breach: count > 0 ? count : undefined });390 }391 }392 return { ok: true, data: results };393 }394 case "GET_SETTINGS":395 return { ok: true, data: await vault.ensureSettingsLoaded() };396 case "UPDATE_SETTINGS": {397 const patch = msg.payload as Record<string, unknown>;398 await vault.updateSettings(patch as any);399 if (typeof patch.autoLockMinutes === "number") {400 await setupAutoLock();401 if (vault.isUnlocked()) touchAutoLock();402 }403 return { ok: true };404 }405 case "GET_PENDING_PASSKEY":406 return { ok: true, data: await getPendingPasskeyRequest() };407 case "APPROVE_PASSKEY": {408 const { requestId } = msg.payload as { requestId: number };409 await resolvePendingPasskeyRequest(requestId, true);410 return { ok: true };411 }412 case "DENY_PASSKEY": {413 const { requestId } = msg.payload as { requestId: number };414 await resolvePendingPasskeyRequest(requestId, false);415 return { ok: true };416 }417 case "GET_PASSKEYS":418 return {419 ok: true,420 data: vault.getEntries().filter((entry) => entry.passkey !== undefined),421 };422 case "DELETE_PASSKEY": {423 const { id } = msg.payload as { id: string };424 await vault.deleteEntry(id);425 return { ok: true };426 }427 case "GET_PASSKEY_PROVIDER_STATE":428 return { ok: true, data: await getPasskeyProviderState() };429 case "SET_PASSKEY_PROVIDER_ENABLED": {430 const { enabled } = msg.payload as { enabled: boolean };431 432 433 if (enabled) {434 const gate = await requirePro("passkeys");435 if (!gate.allowed) return { ok: false, error: "pro_required" };436 }437 return { ok: true, data: await setPasskeyProviderEnabled(enabled) };438 }439 case "FIRE_CANARY_PING": {440 const { webhookUrl, entryTitle, trigger } = msg.payload as {441 webhookUrl: string;442 entryTitle: string;443 trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";444 };445 if (!isAllowedCanaryWebhookUrl(webhookUrl)) {446 return { ok: false, error: "invalid webhook url" };447 }448 const body = JSON.stringify({449 event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",450 entry: entryTitle,451 trigger,452 timestamp: new Date().toISOString(),453 source: "peach-extension",454 });455 if (trigger === "test") {456 457 458 459 460 461 try {462 const res = await fetch(webhookUrl, {463 method: "POST",464 headers: { "Content-Type": "application/json" },465 body,466 });467 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };468 } catch (err) {469 return { ok: false, error: err instanceof Error ? err.message : "network error" };470 }471 }472 473 fetch(webhookUrl, {474 method: "POST",475 headers: { "Content-Type": "application/json" },476 body,477 }).catch(() => {});478 return { ok: true };479 }480 case "ACTIVATE_LICENSE": {481 const { key } = msg.payload as { key: string };482 const licenseInfo = await activateLicense(key);483 return { ok: true, data: licenseInfo };484 }485 case "GET_LICENSE": {486 const licenseInfo = await getLicenseInfo();487 return { ok: true, data: licenseInfo };488 }489 case "GET_LICENSE_KEY": {490 const key = await getLicenseKey();491 return { ok: true, data: key };492 }493 case "DEACTIVATE_LICENSE": {494 await deactivateLicense();495 return { ok: true };496 }497 case "SHOULD_PLAY_CELEBRATION": {498 const should = await shouldPlayCelebration();499 return { ok: true, data: should };500 }501 case "MARK_CELEBRATION_PLAYED": {502 await markCelebrationPlayed();503 return { ok: true };504 }505 case "OPEN_PRO_GATE": {506 507 const { feature } = (msg.payload ?? {}) as { feature?: string };508 if (chrome.storage.session) {509 await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });510 }511 try { await chrome.action.openPopup(); } catch { }512 return { ok: true };513 }514 case "GET_IDENTITIES": {515 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };516 return { ok: true, data: vault.getIdentities() };517 }518 case "GET_ADVANCED_HEALTH_REPORT": {519 const gate = await requirePro("advanced_health");520 if (!gate.allowed) return { ok: false, error: "pro_required" };521 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };522 const report = await buildAdvancedHealthReport(vault.getEntries());523 return { ok: true, data: report };524 }525 case "INITIATE_PASSWORD_CHANGE": {526 const gate = await requirePro("smart_password_change");527 if (!gate.allowed) return { ok: false, error: "pro_required" };528 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };529 const { id } = msg.payload as { id: string };530 const entry = vault.getEntryById(id);531 if (!entry) return { ok: false, error: "entry not found" };532 const url = resolveResetUrl(entry);533 if (!url) return { ok: false, error: "no reset url" };534 try {535 await chrome.tabs.create({ url });536 return { ok: true, data: { url } };537 } catch (err) {538 return {539 ok: false,540 error: err instanceof Error ? err.message : "failed to open tab",541 };542 }543 }544 case "IS_DURESS_ENABLED": {545 const enabled = await vault.isDuressEnabled();546 return { ok: true, data: enabled };547 }548 case "SETUP_DURESS": {549 const { duressPassword } = msg.payload as { duressPassword: string };550 await vault.setupDuress(duressPassword);551 return { ok: true };552 }553 case "DISABLE_DURESS": {554 await vault.disableDuress();555 return { ok: true };556 }557 case "VERIFY_MAIN_PASSWORD": {558 const { password } = msg.payload as { password: string };559 const ok = await vault.verifyMainPassword(password);560 return { ok: true, data: ok };561 }562 case "CHANGE_PASSWORD": {563 const { currentPassword, newPassword } = msg.payload as {564 currentPassword: string;565 newPassword: string;566 };567 const changed = await vault.changePassword(currentPassword, newPassword);568 return changed569 ? { ok: true }570 : { ok: false, error: "incorrect current password" };571 }572 case "GET_SITE_PASSWORD_POLICY": {573 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });574 if (!tab?.id) return { ok: true, data: null };575 try {576 const response = await chrome.tabs.sendMessage(tab.id, {577 type: "GET_SITE_PASSWORD_POLICY",578 });579 return (response as MessageResponse) ?? { ok: true, data: null };580 } catch {581 return { ok: true, data: null };582 }583 }584 case "GET_SYNC_STATE": {585 const { getPairedDevices, getTrustedNetworks } = await import('./sync-storage');586 const { isOnTailscale } = await import('./sync-network');587 const { isProUser } = await import('./license');588 const { storageGet } = await import('../shared/storage');589 const pairedDevices = await getPairedDevices();590 const trustedNetworks = await getTrustedNetworks();591 const tailscale = await isOnTailscale();592 const pro = await isProUser();593594 let status: SyncStatus = "offline";595 if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {596 if (pairedDevices.length === 0) {597 status = "no_peers";598 } else {599 status = "synced";600 }601 } else {602 status = "not_trusted";603 }604605 const lastSyncRaw = await storageGet("peach_last_sync_at");606 const lastSyncParsed = lastSyncRaw ? Number.parseInt(lastSyncRaw, 10) : NaN;607 const lastSyncAt = Number.isFinite(lastSyncParsed) ? lastSyncParsed : undefined;608609 return { ok: true, data: { pairedDevices, trustedNetworks, status, lastSyncAt, isPro: pro } };610 }611 case "TRIGGER_SYNC": {612 const gate = await requirePro("sync");613 if (!gate.allowed) return { ok: false, error: "pro_required" };614 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };615 return { ok: false, error: "no_native_host" };616 }617 case "START_PAIRING": {618 const gate = await requirePro("sync");619 if (!gate.allowed) return { ok: false, error: "pro_required" };620 return { ok: true };621 }622 case "CANCEL_PAIRING": {623 return { ok: true };624 }625 case "ACCEPT_PAIRING": {626 const gate = await requirePro("sync");627 if (!gate.allowed) return { ok: false, error: "pro_required" };628 return { ok: true };629 }630 case "REMOVE_PAIRED_DEVICE": {631 const { deviceId } = msg.payload as { deviceId: string };632 const { removePairedDevice } = await import('./sync-storage');633 await removePairedDevice(deviceId);634 return { ok: true };635 }636 case "REMOVE_TRUSTED_NETWORK": {637 const { ssid, bssid, isTailscale } = msg.payload as any;638 const { removeTrustedNetwork, getTrustedNetworks } = await import('./sync-storage');639 const networks = await getTrustedNetworks();640 const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 641 (isTailscale ? n.isTailscale : n.ssid === ssid) && 642 (!bssid || n.bssid === bssid)643 );644 if (network) {645 await removeTrustedNetwork(network.addedAt);646 }647 return { ok: true };648 }649 case "MARK_NETWORK_TRUSTED": {650 const { addTrustedNetwork } = await import('./sync-storage');651 const { getCurrentNetwork } = await import('./sync-network');652 const network = await getCurrentNetwork();653 await addTrustedNetwork({654 ssid: network.ssid,655 bssid: network.bssid,656 isTailscale: network.isTailscale,657 addedAt: Date.now(),658 });659 return { ok: true };660 }661 case "GET_INCOMING_PAIRING_REQUEST": {662 const { getIncomingPairingRequest } = await import('./sync-storage');663 const request = await getIncomingPairingRequest();664 return { ok: true, data: request };665 }666 case "RESPOND_INCOMING_PAIRING": {667 const { accept } = msg.payload as { accept: boolean };668 const { getIncomingPairingRequest, clearIncomingPairingRequest, addPairedDevice } =669 await import('./sync-storage');670 const request = await getIncomingPairingRequest();671 if (!request) return { ok: false, error: "no_pending_request" };672 if (Date.now() > request.expiresAt) {673 await clearIncomingPairingRequest();674 return { ok: false, error: "expired" };675 }676 await clearIncomingPairingRequest();677 if (accept) {678 await addPairedDevice({679 deviceId: request.publicKeyHex,680 displayName: request.deviceName,681 x25519PublicKey: request.publicKeyHex,682 pairedAt: Date.now(),683 lastSeenAt: Date.now(),684 });685 }686 return { ok: true, data: { accepted: accept, deviceName: request.deviceName } };687 }688 case "START_PEER_DISCOVERY": {689 const gate = await requirePro("sync");690 if (!gate.allowed) return { ok: false, error: "pro_required" };691 return { ok: false, error: "no_native_host" };692 }693 case "STOP_PEER_DISCOVERY": {694 return { ok: true };695 }696 case "START_PAIRING_SERVER": {697 const gate = await requirePro("sync");698 if (!gate.allowed) return { ok: false, error: "pro_required" };699 const token = generateSixDigitToken();700 const expiresAt = Date.now() + PAIRING_TOKEN_TTL_MS;701 const { setActivePairingToken } = await import('./pairing-server-state');702 await setActivePairingToken({ token, expiresAt });703 return { ok: true, data: { token, expiresAt } };704 }705 case "STOP_PAIRING_SERVER": {706 try {707 const { setActivePairingToken } = await import("./pairing-server-state");708 await setActivePairingToken(null);709 } catch { }710 return { ok: true };711 }712 case "CONNECT_WITH_CODE": {713 const gate = await requirePro("sync");714 if (!gate.allowed) return { ok: false, error: "pro_required" };715 return { ok: false, error: "no_native_host" };716 }717 case "GET_RECENT_PAIRING": {718 const { consumeRecentPairing } = await import('./pairing-server-state');719 const recent = await consumeRecentPairing();720 return { ok: true, data: recent };721 }722 case "INITIATE_PAIR_WITH_PEER": {723 const gate = await requirePro("sync");724 if (!gate.allowed) return { ok: false, error: "pro_required" };725 return { ok: false, error: "no_native_host" };726 }727 case "DEBUG_INJECT_INCOMING_PAIRING": {728 729 730 731 732 const { deviceName, publicKeyHex } = (msg.payload ?? {}) as {733 deviceName?: string;734 publicKeyHex?: string;735 };736 if (!deviceName || !publicKeyHex) {737 return { ok: false, error: "missing_payload" };738 }739 const { setIncomingPairingRequest } = await import('./sync-storage');740 const now = Date.now();741 await setIncomingPairingRequest({742 deviceName,743 publicKeyHex,744 receivedAt: now,745 expiresAt: now + 60_000,746 });747 return { ok: true };748 }749 case "RESET_SYNC_IDENTITY": {750 const gate = await requirePro("sync");751 if (!gate.allowed) return { ok: false, error: "pro_required" };752 const { getPairedDevices, removePairedDevice } = await import('./sync-storage');753 const devices = await getPairedDevices();754 for (const device of devices) {755 await removePairedDevice(device.deviceId);756 }757 return { ok: true, data: { removedDevices: devices.length, nativePublicKey: null } };758 }759 case "RECORD_MANUAL_SUMMON": {760 const { domain } = msg.payload as { domain: string };761 const settings = vault.getSettings();762 const existing = settings.manualSummonDomains?.[domain];763 const now = Date.now();764 await vault.updateSettings({765 manualSummonDomains: {766 ...settings.manualSummonDomains,767 [domain]: {768 firstSummonedAt: existing?.firstSummonedAt ?? now,769 summonCount: (existing?.summonCount ?? 0) + 1,770 lastSummonedAt: now,771 },772 },773 });774 return { ok: true };775 }776 case "DECLINE_CARD_SAVE": {777 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };778 await vault.declineCardSaveOffer(last4, expMonth, expYear);779 return { ok: true };780 }781 case "IS_CARD_DECLINED": {782 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };783 const declined = await vault.isCardDeclined(last4, expMonth, expYear);784 return { ok: true, data: declined };785 }786 case "INITIATE_FIREFOX_PASSKEY_CREATE": {787 const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {788 rpId?: string;789 rpName?: string;790 userName?: string;791 userDisplayName?: string;792 };793 if (!rpId || !userName) return { ok: false, error: "rpId and userName required" };794 return await initiateFirefoxPasskeyCreate({795 rpId,796 rpName: rpName ?? rpId,797 userName,798 userDisplayName,799 });800 }801802 case "COMPLETE_FIREFOX_PASSKEY_CREATE": {803 const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;804 if (!payload) return { ok: false, error: "missing payload" };805 return await completeFirefoxPasskeyCreate(payload);806 }807808 case "PASSKEY_OFFER_DECLINE": {809 const { domain } = (msg.payload ?? {}) as { domain?: string };810 if (!domain) return { ok: false, error: "domain required" };811 await recordPasskeyOfferDecline(domain);812 return { ok: true };813 }814815 case "GET_PASSKEY_OFFER_DECLINED": {816 const { domain } = (msg.payload ?? {}) as { domain?: string };817 if (!domain) return { ok: false, error: "domain required" };818 return { ok: true, data: await isPasskeyOfferDeclined(domain) };819 }820821 case "PASSKEY_HAS_CREDENTIALS": {822 const payload = msg.payload as {823 rpId?: string;824 allowCredentials?: Array<{ id: string; type?: string }>;825 } | undefined;826 if (!payload?.rpId) return { ok: false, error: "rpId required" };827 return await handlePasskeyHasCredentials({828 rpId: payload.rpId,829 allowCredentials: payload.allowCredentials ?? [],830 });831 }832833 case "PASSKEY_CREATE": {834 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;835 if (!payload?.rpId || !payload?.userName) {836 return { ok: false, error: "rpId and userName required" };837 }838 return await handleFirefoxPasskeyCreate(payload);839 }840841 case "PASSKEY_GET": {842 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;843 if (!payload?.rpId) return { ok: false, error: "rpId required" };844 return await handleFirefoxPasskeyGet(payload);845 }846847 case "GET_PASSKEY_FOR_DOMAIN": {848 const { domain } = (msg.payload ?? {}) as { domain?: string };849 if (!domain) return { ok: false, error: "domain required" };850 if (!vault.isUnlocked()) return { ok: true, data: null };851 const match = vault852 .getEntries()853 .find((entry) => entry.passkey?.rpId === domain);854 if (!match || !match.passkey) return { ok: true, data: null };855 856 857 return {858 ok: true,859 data: {860 rpId: match.passkey.rpId,861 userName: match.passkey.userName,862 browser: match.passkey.browser,863 },864 };865 }866867 case "DESTROY_VAULT": {868 869 870 871 872 873 vault.lock();874 await storageClear();875 await vault.clearVaultHistory();876 if (chrome.storage.session) {877 await chrome.storage.session.clear();878 }879 return { ok: true };880 }881882 case "SUBMIT_FEEDBACK": {883 const { category, message, version, browser } = msg.payload as {884 category: string;885 message: string;886 version: string;887 browser: string;888 };889 if (!message.trim()) return { ok: false, error: "message is empty" };890 if (!FEEDBACK_ENDPOINT) return { ok: true };891 if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {892 return { ok: false, error: "invalid feedback endpoint" };893 }894 try {895 const res = await fetch(FEEDBACK_ENDPOINT, {896 method: "POST",897 headers: { "Content-Type": "application/json" },898 body: JSON.stringify({ category, message, version, browser, source: "peach-extension" }),899 });900 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };901 } catch (err) {902 return { ok: false, error: err instanceof Error ? err.message : "network error" };903 }904 }905 default:906 return { ok: false, error: "unknown message type" };907 }908 } catch (err) {909 return {910 ok: false,911 error: err instanceof Error ? err.message : "operation failed",912 };913 }914}915916917function broadcastToContentScripts(message: { type: string }): void {918 if (!chrome.tabs?.query) return;919 chrome.tabs.query({}, (tabs) => {920 for (const tab of tabs) {921 if (tab.id !== undefined) {922 chrome.tabs.sendMessage(tab.id, message).catch(() => {923 924 });925 }926 }927 });928}929930function shouldRefreshAutoLock(type: MessageType): boolean {931 return (932 type !== "HAS_VAULT" &&933 type !== "IS_UNLOCKED" &&934 type !== "GET_VAULT_STATUS" &&935 type !== "LOCK" &&936 type !== "UPDATE_SETTINGS" &&937 type !== "GET_PENDING_PASSKEY" &&938 type !== "GET_PASSKEY_PROVIDER_STATE" &&939 type !== "DECLINE_CARD_SAVE" &&940 type !== "IS_CARD_DECLINED" &&941 type !== "SUBMIT_FEEDBACK"942 );943}944945function isAllowedCanaryWebhookUrl(value: string): boolean {946 try {947 const url = new URL(value);948 if (url.protocol !== "https:") return false;949950 const hostname = url.hostname.toLowerCase();951 if (952 hostname === "localhost" ||953 hostname.endsWith(".localhost") ||954 isPrivateIpv4(hostname) ||955 isPrivateIpv6(hostname)956 ) {957 return false;958 }959960 return true;961 } catch {962 return false;963 }964}965966function isPrivateIpv4(hostname: string): boolean {967 if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {968 return true;969 }970971 const octets = hostname.split(".").map((part) => Number(part));972 if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {973 return false;974 }975976 return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;977}978979function isPrivateIpv6(hostname: string): boolean {980 const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();981 return (982 normalized === "::1" ||983 normalized.startsWith("fc") ||984 normalized.startsWith("fd") ||985 normalized.startsWith("fe80:")986 );987}