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