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