123456789101112import { copyToClipboard } from "./clipboard";13import { setupAutoLock, touchAutoLock } from "./alarms";14import * as vault from "./vault";15import { activateLicense, getLicenseInfo } from "./license";16import {17 getPasskeyProviderState,18 getPendingPasskeyRequest,19 resolvePendingPasskeyRequest,20 setPasskeyProviderEnabled,21} from "./passkey";22import type { Message, MessageResponse, MessageType } from "../shared/types";23import type { SyncStatus } from "../shared/sync-types";2425const PRIVATE_IPV4_RANGES = [26 { prefix: "10." },27 { prefix: "127." },28 { prefix: "169.254." },29 { prefix: "192.168." },30];3132export function setupMessageHandler(): void {33 chrome.runtime.onMessage.addListener(34 (msg: unknown, sender, sendResponse) => {35 if (!isMessage(msg)) {36 sendResponse({ ok: false, error: "invalid message" });37 return true;38 }39 handleMessage(msg, sender)40 .then(sendResponse)41 .catch((err) =>42 sendResponse({43 ok: false,44 error: err instanceof Error ? err.message : String(err),45 })46 );47 return true;48 }49 );50}5152function isMessage(m: unknown): m is Message {53 return typeof m === "object" && m !== null && "type" in m;54}5556async function handleMessage(57 msg: Message,58 sender: chrome.runtime.MessageSender59): Promise<MessageResponse> {60 try {61 62 63 64 65 if (sender.id !== undefined && sender.id !== chrome.runtime.id) {66 return { ok: false, error: "invalid sender" };67 }6869 if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {70 touchAutoLock();71 }7273 switch (msg.type) {74 case "CREATE_VAULT": {75 const { password } = msg.payload as { password: string };76 await vault.createVault(password);77 await setupAutoLock();78 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });79 return { ok: true };80 }81 case "UNLOCK": {82 const { password } = msg.payload as { password: string };83 const ok = await vault.unlock(password);84 if (ok) {85 await setupAutoLock();86 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });87 }88 return { ok };89 }90 case "LOCK":91 vault.lock();92 return { ok: true };93 case "IS_UNLOCKED":94 return { ok: true, data: vault.isUnlocked() };95 case "HAS_VAULT":96 return { ok: true, data: await vault.hasVault() };97 case "GET_ENTRIES":98 return { ok: true, data: vault.getEntries() };99 case "ADD_ENTRY": {100 const entry = await vault.addEntry(msg.payload as any);101 return { ok: true, data: entry };102 }103 case "BATCH_ADD_ENTRIES": {104 const { entries } = msg.payload as { entries: any[] };105 const added = await vault.batchAddEntries(entries);106 return { ok: true, data: added };107 }108 case "RESTORE_ENTRIES": {109 const { entries, mode, settings } = msg.payload as {110 entries: any[];111 mode: "replace" | "merge";112 settings?: vault.VaultSettings;113 };114 const summary = await vault.restoreEntries(entries, mode);115 if (settings && typeof settings === "object") {116 await vault.updateSettings(settings);117 }118 return { ok: true, data: summary };119 }120 case "CLEAR_VAULT_ENTRIES": {121 await vault.clearVaultEntries("REPLACE_ALL");122 return { ok: true };123 }124 case "LIST_VAULT_SNAPSHOTS": {125 const snapshots = await vault.listSnapshots();126 return { ok: true, data: snapshots };127 }128 case "RESTORE_VAULT_SNAPSHOT": {129 const { index, password } = msg.payload as { index: number; password: string };130 const ok = await vault.restoreFromSnapshot(index, password);131 if (ok) broadcastToContentScripts({ type: "VAULT_UNLOCKED" });132 return { ok };133 }134 case "COUNT_DUPLICATES": {135 return { ok: true, data: vault.countDuplicates() };136 }137 case "DEDUPLICATE_ENTRIES": {138 const removed = await vault.deduplicateEntries();139 return { ok: true, data: removed };140 }141 case "UPDATE_ENTRY": {142 const { id, patch } = msg.payload as any;143 await vault.updateEntry(id, patch);144 return { ok: true };145 }146 case "DELETE_ENTRY": {147 const { id } = msg.payload as { id: string };148 await vault.deleteEntry(id);149 return { ok: true };150 }151 case "GET_DELETED_ENTRIES":152 return { ok: true, data: vault.getDeletedEntries() };153 case "RESTORE_ENTRY": {154 const { id } = msg.payload as { id: string };155 await vault.restoreEntry(id);156 return { ok: true };157 }158 case "PURGE_ENTRY": {159 const { id } = msg.payload as { id: string };160 await vault.purgeEntry(id);161 return { ok: true };162 }163 case "PURGE_EXPIRED_TRASH": {164 const { retentionMs } = msg.payload as { retentionMs: number };165 const purged = await vault.purgeExpiredTrash(retentionMs);166 return { ok: true, data: purged };167 }168 case "AUTOFILL_QUERY": {169 if (!vault.isUnlocked()) {170 return { ok: false, error: "VAULT_LOCKED" };171 }172 const { domain, includeAll } = msg.payload as {173 domain: string;174 includeAll?: boolean;175 };176 const matches = includeAll177 ? vault178 .getEntries()179 .filter((entry) => entry.type !== "note" && entry.type !== "passkey" && entry.type !== "card")180 : vault.queryDomain(domain);181 return {182 ok: true,183 data: matches.map((e) =>184 includeAll185 ? {186 id: e.id,187 title: e.title,188 }189 : {190 id: e.id,191 title: e.title,192 username: e.username,193 url: e.url,194 hasTotp: Boolean(e.totp),195 }196 ),197 };198 }199 case "GET_ENTRY_FOR_FILL": {200 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };201 const { id } = msg.payload as { id: string };202 const entry = vault.getEntryById(id);203 if (!entry) return { ok: false, error: "entry not found" };204 if (entry.type === "passkey") return { ok: false, error: "passkey not fillable" };205 206 207 const { privateKeyJwk: _stripped, ...safeEntry } = entry;208 return { ok: true, data: safeEntry };209 }210 case "COPY": {211 const { text, label } = msg.payload as { text: string; label: string };212 await copyToClipboard(text, label);213 return { ok: true };214 }215 case "CHECK_BREACH": {216 const { password } = msg.payload as { password: string };217 const count = await vault.checkBreach(password);218 return { ok: true, data: count };219 }220 case "CHECK_ALL_BREACHES": {221 const allEntries = vault.getEntries();222 const results: Record<string, number> = {};223 for (const entry of allEntries) {224 if (entry.type === "note") continue;225 if (!entry.password) continue;226 results[entry.id] = await vault.checkBreach(entry.password);227 228 await new Promise((r) => setTimeout(r, 200));229 }230 231 for (const [id, count] of Object.entries(results)) {232 const entry = vault.getEntryById(id);233 if (entry && (entry.breach ?? 0) !== count) {234 await vault.updateEntry(id, { breach: count > 0 ? count : undefined });235 }236 }237 return { ok: true, data: results };238 }239 case "GET_SETTINGS":240 return { ok: true, data: await vault.ensureSettingsLoaded() };241 case "UPDATE_SETTINGS": {242 const patch = msg.payload as Record<string, unknown>;243 await vault.updateSettings(patch as any);244 if (typeof patch.autoLockMinutes === "number") {245 await setupAutoLock();246 if (vault.isUnlocked()) touchAutoLock();247 }248 return { ok: true };249 }250 case "GET_PENDING_PASSKEY":251 return { ok: true, data: await getPendingPasskeyRequest() };252 case "APPROVE_PASSKEY": {253 const { requestId } = msg.payload as { requestId: number };254 await resolvePendingPasskeyRequest(requestId, true);255 return { ok: true };256 }257 case "DENY_PASSKEY": {258 const { requestId } = msg.payload as { requestId: number };259 await resolvePendingPasskeyRequest(requestId, false);260 return { ok: true };261 }262 case "GET_PASSKEYS":263 return {264 ok: true,265 data: vault.getEntries().filter((entry) => entry.type === "passkey"),266 };267 case "DELETE_PASSKEY": {268 const { id } = msg.payload as { id: string };269 await vault.deleteEntry(id);270 return { ok: true };271 }272 case "GET_PASSKEY_PROVIDER_STATE":273 return { ok: true, data: await getPasskeyProviderState() };274 case "SET_PASSKEY_PROVIDER_ENABLED": {275 const { enabled } = msg.payload as { enabled: boolean };276 return { ok: true, data: await setPasskeyProviderEnabled(enabled) };277 }278 case "FIRE_CANARY_PING": {279 const { webhookUrl, entryTitle, trigger } = msg.payload as {280 webhookUrl: string;281 entryTitle: string;282 trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";283 };284 if (!isAllowedCanaryWebhookUrl(webhookUrl)) {285 return { ok: false, error: "invalid webhook url" };286 }287 const body = JSON.stringify({288 event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",289 entry: entryTitle,290 trigger,291 timestamp: new Date().toISOString(),292 source: "peach-extension",293 });294 if (trigger === "test") {295 296 297 298 299 300 try {301 const res = await fetch(webhookUrl, {302 method: "POST",303 headers: { "Content-Type": "application/json" },304 body,305 });306 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };307 } catch (err) {308 return { ok: false, error: err instanceof Error ? err.message : "network error" };309 }310 }311 312 fetch(webhookUrl, {313 method: "POST",314 headers: { "Content-Type": "application/json" },315 body,316 }).catch(() => {});317 return { ok: true };318 }319 case "ACTIVATE_LICENSE": {320 const { key } = msg.payload as { key: string };321 const licenseInfo = await activateLicense(key);322 return { ok: true, data: licenseInfo };323 }324 case "GET_LICENSE": {325 const licenseInfo = await getLicenseInfo();326 return { ok: true, data: licenseInfo };327 }328 case "IS_DURESS_ENABLED": {329 const enabled = await vault.isDuressEnabled();330 return { ok: true, data: enabled };331 }332 case "SETUP_DURESS": {333 const { duressPassword } = msg.payload as { duressPassword: string };334 await vault.setupDuress(duressPassword);335 return { ok: true };336 }337 case "DISABLE_DURESS": {338 await vault.disableDuress();339 return { ok: true };340 }341 case "VERIFY_MAIN_PASSWORD": {342 const { password } = msg.payload as { password: string };343 const ok = await vault.verifyMainPassword(password);344 return { ok: true, data: ok };345 }346 case "CHANGE_PASSWORD": {347 const { currentPassword, newPassword } = msg.payload as {348 currentPassword: string;349 newPassword: string;350 };351 const changed = await vault.changePassword(currentPassword, newPassword);352 return changed353 ? { ok: true }354 : { ok: false, error: "incorrect current password" };355 }356 case "GET_SITE_PASSWORD_POLICY": {357 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });358 if (!tab?.id) return { ok: true, data: null };359 try {360 const response = await chrome.tabs.sendMessage(tab.id, {361 type: "GET_SITE_PASSWORD_POLICY",362 });363 return (response as MessageResponse) ?? { ok: true, data: null };364 } catch {365 return { ok: true, data: null };366 }367 }368 case "GET_SYNC_STATE": {369 const { getPairedDevices, getTrustedNetworks } = await import('./sync-storage');370 const { isOnTailscale } = await import('./sync-network');371 const { isProUser } = await import('./license');372 const pairedDevices = await getPairedDevices();373 const trustedNetworks = await getTrustedNetworks();374 const tailscale = await isOnTailscale();375 const pro = await isProUser();376 377 let status: SyncStatus = "offline";378 if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {379 if (pairedDevices.length === 0) {380 status = "no_peers";381 } else {382 status = "synced";383 }384 } else {385 status = "not_trusted";386 }387 388 return { ok: true, data: { pairedDevices, trustedNetworks, status, isPro: pro } };389 }390 case "TRIGGER_SYNC": {391 return { ok: true };392 }393 case "START_PAIRING": {394 return { ok: true };395 }396 case "CANCEL_PAIRING": {397 return { ok: true };398 }399 case "ACCEPT_PAIRING": {400 return { ok: true };401 }402 case "REMOVE_PAIRED_DEVICE": {403 const { deviceId } = msg.payload as { deviceId: string };404 const { removePairedDevice } = await import('./sync-storage');405 await removePairedDevice(deviceId);406 return { ok: true };407 }408 case "REMOVE_TRUSTED_NETWORK": {409 const { ssid, bssid, isTailscale } = msg.payload as any;410 const { removeTrustedNetwork, getTrustedNetworks } = await import('./sync-storage');411 const networks = await getTrustedNetworks();412 const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 413 (isTailscale ? n.isTailscale : n.ssid === ssid) && 414 (!bssid || n.bssid === bssid)415 );416 if (network) {417 await removeTrustedNetwork(network.addedAt);418 }419 return { ok: true };420 }421 case "MARK_NETWORK_TRUSTED": {422 const { addTrustedNetwork } = await import('./sync-storage');423 const { getCurrentNetwork } = await import('./sync-network');424 const network = await getCurrentNetwork();425 await addTrustedNetwork({426 ssid: network.ssid,427 bssid: network.bssid,428 isTailscale: network.isTailscale,429 addedAt: Date.now(),430 });431 return { ok: true };432 }433 case "RECORD_MANUAL_SUMMON": {434 const { domain } = msg.payload as { domain: string };435 const settings = vault.getSettings();436 const existing = settings.manualSummonDomains?.[domain];437 const now = Date.now();438 await vault.updateSettings({439 manualSummonDomains: {440 ...settings.manualSummonDomains,441 [domain]: {442 firstSummonedAt: existing?.firstSummonedAt ?? now,443 summonCount: (existing?.summonCount ?? 0) + 1,444 lastSummonedAt: now,445 },446 },447 });448 return { ok: true };449 }450 case "DECLINE_CARD_SAVE": {451 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };452 await vault.declineCardSaveOffer(last4, expMonth, expYear);453 return { ok: true };454 }455 case "IS_CARD_DECLINED": {456 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };457 const declined = await vault.isCardDeclined(last4, expMonth, expYear);458 return { ok: true, data: declined };459 }460 default:461 return { ok: false, error: "unknown message type" };462 }463 } catch (err) {464 return {465 ok: false,466 error: err instanceof Error ? err.message : "operation failed",467 };468 }469}470471472function broadcastToContentScripts(message: { type: string }): void {473 chrome.tabs.query({}, (tabs) => {474 for (const tab of tabs) {475 if (tab.id !== undefined) {476 chrome.tabs.sendMessage(tab.id, message).catch(() => {477 478 });479 }480 }481 });482}483484function shouldRefreshAutoLock(type: MessageType): boolean {485 return (486 type !== "HAS_VAULT" &&487 type !== "IS_UNLOCKED" &&488 type !== "LOCK" &&489 type !== "UPDATE_SETTINGS" &&490 type !== "GET_PENDING_PASSKEY" &&491 type !== "GET_PASSKEY_PROVIDER_STATE" &&492 type !== "DECLINE_CARD_SAVE" &&493 type !== "IS_CARD_DECLINED"494 );495}496497function isAllowedCanaryWebhookUrl(value: string): boolean {498 try {499 const url = new URL(value);500 if (url.protocol !== "https:") return false;501502 const hostname = url.hostname.toLowerCase();503 if (504 hostname === "localhost" ||505 hostname.endsWith(".localhost") ||506 isPrivateIpv4(hostname) ||507 isPrivateIpv6(hostname)508 ) {509 return false;510 }511512 return true;513 } catch {514 return false;515 }516}517518function isPrivateIpv4(hostname: string): boolean {519 if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {520 return true;521 }522523 const octets = hostname.split(".").map((part) => Number(part));524 if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {525 return false;526 }527528 return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;529}530531function isPrivateIpv6(hostname: string): boolean {532 const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();533 return (534 normalized === "::1" ||535 normalized.startsWith("fc") ||536 normalized.startsWith("fd") ||537 normalized.startsWith("fe80:")538 );539}