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