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