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, alreadyWritten } = msg.payload as {214 text: string;215 label: string;216 alreadyWritten?: boolean;217 };218 await copyToClipboard(text, label, Boolean(alreadyWritten));219 return { ok: true };220 }221 case "CHECK_BREACH": {222 const { password } = msg.payload as { password: string };223 const count = await vault.checkBreach(password);224 return { ok: true, data: count };225 }226 case "CHECK_ALL_BREACHES": {227 const allEntries = vault.getEntries();228 const results: Record<string, number> = {};229 for (const entry of allEntries) {230 if (entry.type === "note") continue;231 if (!entry.password) continue;232 results[entry.id] = await vault.checkBreach(entry.password);233 234 await new Promise((r) => setTimeout(r, 200));235 }236 237 for (const [id, count] of Object.entries(results)) {238 const entry = vault.getEntryById(id);239 if (entry && (entry.breach ?? 0) !== count) {240 await vault.updateEntry(id, { breach: count > 0 ? count : undefined });241 }242 }243 return { ok: true, data: results };244 }245 case "GET_SETTINGS":246 return { ok: true, data: await vault.ensureSettingsLoaded() };247 case "UPDATE_SETTINGS": {248 const patch = msg.payload as Record<string, unknown>;249 await vault.updateSettings(patch as any);250 if (typeof patch.autoLockMinutes === "number") {251 await setupAutoLock();252 if (vault.isUnlocked()) touchAutoLock();253 }254 return { ok: true };255 }256 case "GET_PENDING_PASSKEY":257 return { ok: true, data: await getPendingPasskeyRequest() };258 case "APPROVE_PASSKEY": {259 const { requestId } = msg.payload as { requestId: number };260 await resolvePendingPasskeyRequest(requestId, true);261 return { ok: true };262 }263 case "DENY_PASSKEY": {264 const { requestId } = msg.payload as { requestId: number };265 await resolvePendingPasskeyRequest(requestId, false);266 return { ok: true };267 }268 case "GET_PASSKEYS":269 return {270 ok: true,271 data: vault.getEntries().filter((entry) => entry.type === "passkey"),272 };273 case "DELETE_PASSKEY": {274 const { id } = msg.payload as { id: string };275 await vault.deleteEntry(id);276 return { ok: true };277 }278 case "GET_PASSKEY_PROVIDER_STATE":279 return { ok: true, data: await getPasskeyProviderState() };280 case "SET_PASSKEY_PROVIDER_ENABLED": {281 const { enabled } = msg.payload as { enabled: boolean };282 return { ok: true, data: await setPasskeyProviderEnabled(enabled) };283 }284 case "FIRE_CANARY_PING": {285 const { webhookUrl, entryTitle, trigger } = msg.payload as {286 webhookUrl: string;287 entryTitle: string;288 trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";289 };290 if (!isAllowedCanaryWebhookUrl(webhookUrl)) {291 return { ok: false, error: "invalid webhook url" };292 }293 const body = JSON.stringify({294 event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",295 entry: entryTitle,296 trigger,297 timestamp: new Date().toISOString(),298 source: "peach-extension",299 });300 if (trigger === "test") {301 302 303 304 305 306 try {307 const res = await fetch(webhookUrl, {308 method: "POST",309 headers: { "Content-Type": "application/json" },310 body,311 });312 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };313 } catch (err) {314 return { ok: false, error: err instanceof Error ? err.message : "network error" };315 }316 }317 318 fetch(webhookUrl, {319 method: "POST",320 headers: { "Content-Type": "application/json" },321 body,322 }).catch(() => {});323 return { ok: true };324 }325 case "ACTIVATE_LICENSE": {326 const { key } = msg.payload as { key: string };327 const licenseInfo = await activateLicense(key);328 return { ok: true, data: licenseInfo };329 }330 case "GET_LICENSE": {331 const licenseInfo = await getLicenseInfo();332 return { ok: true, data: licenseInfo };333 }334 case "IS_DURESS_ENABLED": {335 const enabled = await vault.isDuressEnabled();336 return { ok: true, data: enabled };337 }338 case "SETUP_DURESS": {339 const { duressPassword } = msg.payload as { duressPassword: string };340 await vault.setupDuress(duressPassword);341 return { ok: true };342 }343 case "DISABLE_DURESS": {344 await vault.disableDuress();345 return { ok: true };346 }347 case "VERIFY_MAIN_PASSWORD": {348 const { password } = msg.payload as { password: string };349 const ok = await vault.verifyMainPassword(password);350 return { ok: true, data: ok };351 }352 case "CHANGE_PASSWORD": {353 const { currentPassword, newPassword } = msg.payload as {354 currentPassword: string;355 newPassword: string;356 };357 const changed = await vault.changePassword(currentPassword, newPassword);358 return changed359 ? { ok: true }360 : { ok: false, error: "incorrect current password" };361 }362 case "GET_SITE_PASSWORD_POLICY": {363 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });364 if (!tab?.id) return { ok: true, data: null };365 try {366 const response = await chrome.tabs.sendMessage(tab.id, {367 type: "GET_SITE_PASSWORD_POLICY",368 });369 return (response as MessageResponse) ?? { ok: true, data: null };370 } catch {371 return { ok: true, data: null };372 }373 }374 case "GET_SYNC_STATE": {375 const { getPairedDevices, getTrustedNetworks } = await import('./sync-storage');376 const { isOnTailscale } = await import('./sync-network');377 const { isProUser } = await import('./license');378 const pairedDevices = await getPairedDevices();379 const trustedNetworks = await getTrustedNetworks();380 const tailscale = await isOnTailscale();381 const pro = await isProUser();382 383 let status: SyncStatus = "offline";384 if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {385 if (pairedDevices.length === 0) {386 status = "no_peers";387 } else {388 status = "synced";389 }390 } else {391 status = "not_trusted";392 }393 394 return { ok: true, data: { pairedDevices, trustedNetworks, status, isPro: pro } };395 }396 case "TRIGGER_SYNC": {397 return { ok: true };398 }399 case "START_PAIRING": {400 return { ok: true };401 }402 case "CANCEL_PAIRING": {403 return { ok: true };404 }405 case "ACCEPT_PAIRING": {406 return { ok: true };407 }408 case "REMOVE_PAIRED_DEVICE": {409 const { deviceId } = msg.payload as { deviceId: string };410 const { removePairedDevice } = await import('./sync-storage');411 await removePairedDevice(deviceId);412 return { ok: true };413 }414 case "REMOVE_TRUSTED_NETWORK": {415 const { ssid, bssid, isTailscale } = msg.payload as any;416 const { removeTrustedNetwork, getTrustedNetworks } = await import('./sync-storage');417 const networks = await getTrustedNetworks();418 const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 419 (isTailscale ? n.isTailscale : n.ssid === ssid) && 420 (!bssid || n.bssid === bssid)421 );422 if (network) {423 await removeTrustedNetwork(network.addedAt);424 }425 return { ok: true };426 }427 case "MARK_NETWORK_TRUSTED": {428 const { addTrustedNetwork } = await import('./sync-storage');429 const { getCurrentNetwork } = await import('./sync-network');430 const network = await getCurrentNetwork();431 await addTrustedNetwork({432 ssid: network.ssid,433 bssid: network.bssid,434 isTailscale: network.isTailscale,435 addedAt: Date.now(),436 });437 return { ok: true };438 }439 case "RECORD_MANUAL_SUMMON": {440 const { domain } = msg.payload as { domain: string };441 const settings = vault.getSettings();442 const existing = settings.manualSummonDomains?.[domain];443 const now = Date.now();444 await vault.updateSettings({445 manualSummonDomains: {446 ...settings.manualSummonDomains,447 [domain]: {448 firstSummonedAt: existing?.firstSummonedAt ?? now,449 summonCount: (existing?.summonCount ?? 0) + 1,450 lastSummonedAt: now,451 },452 },453 });454 return { ok: true };455 }456 case "DECLINE_CARD_SAVE": {457 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };458 await vault.declineCardSaveOffer(last4, expMonth, expYear);459 return { ok: true };460 }461 case "IS_CARD_DECLINED": {462 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };463 const declined = await vault.isCardDeclined(last4, expMonth, expYear);464 return { ok: true, data: declined };465 }466 default:467 return { ok: false, error: "unknown message type" };468 }469 } catch (err) {470 return {471 ok: false,472 error: err instanceof Error ? err.message : "operation failed",473 };474 }475}476477478function broadcastToContentScripts(message: { type: string }): void {479 chrome.tabs.query({}, (tabs) => {480 for (const tab of tabs) {481 if (tab.id !== undefined) {482 chrome.tabs.sendMessage(tab.id, message).catch(() => {483 484 });485 }486 }487 });488}489490function shouldRefreshAutoLock(type: MessageType): boolean {491 return (492 type !== "HAS_VAULT" &&493 type !== "IS_UNLOCKED" &&494 type !== "GET_VAULT_STATUS" &&495 type !== "LOCK" &&496 type !== "UPDATE_SETTINGS" &&497 type !== "GET_PENDING_PASSKEY" &&498 type !== "GET_PASSKEY_PROVIDER_STATE" &&499 type !== "DECLINE_CARD_SAVE" &&500 type !== "IS_CARD_DECLINED"501 );502}503504function isAllowedCanaryWebhookUrl(value: string): boolean {505 try {506 const url = new URL(value);507 if (url.protocol !== "https:") return false;508509 const hostname = url.hostname.toLowerCase();510 if (511 hostname === "localhost" ||512 hostname.endsWith(".localhost") ||513 isPrivateIpv4(hostname) ||514 isPrivateIpv6(hostname)515 ) {516 return false;517 }518519 return true;520 } catch {521 return false;522 }523}524525function isPrivateIpv4(hostname: string): boolean {526 if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {527 return true;528 }529530 const octets = hostname.split(".").map((part) => Number(part));531 if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {532 return false;533 }534535 return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;536}537538function isPrivateIpv6(hostname: string): boolean {539 const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();540 return (541 normalized === "::1" ||542 normalized.startsWith("fc") ||543 normalized.startsWith("fd") ||544 normalized.startsWith("fe80:")545 );546}