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