123456789101112import { copyToClipboard } from "./clipboard";13import { setupAutoLock, touchAutoLock } from "./alarms";14import * as vault from "./vault";15import {16 activateLicense,17 deactivateLicense,18 getLicenseInfo,19 getLicenseKey,20 isProUser,21 markCelebrationPlayed,22 shouldPlayCelebration,23} from "./license";24import {25 getPasskeyProviderState,26 getPendingPasskeyRequest,27 resolvePendingPasskeyRequest,28 setPasskeyProviderEnabled,29} from "./passkey";30import { buildAdvancedHealthReport, resolveResetUrl } from "./health";31import type {32 Message,33 MessageResponse,34 MessageType,35 ProFeature,36} from "../shared/types";37import type { SyncStatus } from "../shared/sync-types";3839404142434445async function requirePro(46 feature: ProFeature,47 trigger: "user" | "background" = "user"48): Promise<{ allowed: boolean }> {49 if (await isProUser()) return { allowed: true };5051 if (trigger === "user") {52 53 chrome.runtime54 .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })55 .catch(() => {56 57 });58 } else {59 60 61 62 if (chrome.storage.session) {63 chrome.storage.session64 .set({ pendingProNotice: { feature, triggeredAt: Date.now() } })65 .catch(() => {});66 }67 }6869 return { allowed: false };70}7172const PRIVATE_IPV4_RANGES = [73 { prefix: "10." },74 { prefix: "127." },75 { prefix: "169.254." },76 { prefix: "192.168." },77];787980818283const FEEDBACK_ENDPOINT = "";8485export function setupMessageHandler(): void {86 chrome.runtime.onMessage.addListener(87 (msg: unknown, sender, sendResponse) => {88 if (!isMessage(msg)) {89 sendResponse({ ok: false, error: "invalid message" });90 return true;91 }92 handleMessage(msg, sender)93 .then(sendResponse)94 .catch((err) =>95 sendResponse({96 ok: false,97 error: err instanceof Error ? err.message : String(err),98 })99 );100 return true;101 }102 );103}104105function isMessage(m: unknown): m is Message {106 return typeof m === "object" && m !== null && "type" in m;107}108109async function handleMessage(110 msg: Message,111 sender: chrome.runtime.MessageSender112): Promise<MessageResponse> {113 try {114 115 116 117 118 if (sender.id !== undefined && sender.id !== chrome.runtime.id) {119 return { ok: false, error: "invalid sender" };120 }121122 if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {123 touchAutoLock();124 }125126 switch (msg.type) {127 case "CREATE_VAULT": {128 const { password } = msg.payload as { password: string };129 await vault.createVault(password);130 await setupAutoLock();131 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });132 return { ok: true };133 }134 case "UNLOCK": {135 const { password } = msg.payload as { password: string };136 const ok = await vault.unlock(password);137 if (ok) {138 await setupAutoLock();139 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });140 }141 return { ok };142 }143 case "LOCK":144 vault.lock();145 return { ok: true };146 case "IS_UNLOCKED":147 return { ok: true, data: vault.isUnlocked() };148 case "GET_VAULT_STATUS":149 return { ok: true, unlocked: vault.isUnlocked() };150 case "HAS_VAULT":151 return { ok: true, data: await vault.hasVault() };152 case "GET_ENTRIES":153 return { ok: true, data: vault.getEntries() };154 case "ADD_ENTRY": {155 const entry = await vault.addEntry(msg.payload as any);156 return { ok: true, data: entry };157 }158 case "BATCH_ADD_ENTRIES": {159 const { entries } = msg.payload as { entries: any[] };160 const added = await vault.batchAddEntries(entries);161 return { ok: true, data: added };162 }163 case "RESTORE_ENTRIES": {164 const { entries, mode, settings } = msg.payload as {165 entries: any[];166 mode: "replace" | "merge";167 settings?: vault.VaultSettings;168 };169 const summary = await vault.restoreEntries(entries, mode);170 if (settings && typeof settings === "object") {171 await vault.updateSettings(settings);172 }173 return { ok: true, data: summary };174 }175 case "CLEAR_VAULT_ENTRIES": {176 await vault.clearVaultEntries("REPLACE_ALL");177 return { ok: true };178 }179 case "LIST_VAULT_SNAPSHOTS": {180 const snapshots = await vault.listSnapshots();181 return { ok: true, data: snapshots };182 }183 case "RESTORE_VAULT_SNAPSHOT": {184 const { index, password } = msg.payload as { index: number; password: string };185 const ok = await vault.restoreFromSnapshot(index, password);186 if (ok) broadcastToContentScripts({ type: "VAULT_UNLOCKED" });187 return { ok };188 }189 case "COUNT_DUPLICATES": {190 return { ok: true, data: vault.countDuplicates() };191 }192 case "DEDUPLICATE_ENTRIES": {193 const removed = await vault.deduplicateEntries();194 return { ok: true, data: removed };195 }196 case "UPDATE_ENTRY": {197 const { id, patch } = msg.payload as any;198 await vault.updateEntry(id, patch);199 return { ok: true };200 }201 case "ADD_ADDITIONAL_URL": {202 const { id, url } = msg.payload as { id: string; url: string };203 const trimmed = typeof url === "string" ? url.trim() : "";204 if (!trimmed) return { ok: false, error: "url required" };205 const existing = vault.getEntryById(id);206 if (!existing) return { ok: false, error: "entry not found" };207 if (existing.type !== "login") return { ok: false, error: "login entries only" };208 const merged = [...(existing.additionalUrls ?? []), trimmed];209 await vault.updateEntry(id, { additionalUrls: merged });210 return { ok: true };211 }212 case "DELETE_ENTRY": {213 const { id } = msg.payload as { id: string };214 await vault.deleteEntry(id);215 return { ok: true };216 }217 case "GET_DELETED_ENTRIES":218 return { ok: true, data: vault.getDeletedEntries() };219 case "RESTORE_ENTRY": {220 const { id } = msg.payload as { id: string };221 await vault.restoreEntry(id);222 return { ok: true };223 }224 case "PURGE_ENTRY": {225 const { id } = msg.payload as { id: string };226 await vault.purgeEntry(id);227 return { ok: true };228 }229 case "PURGE_EXPIRED_TRASH": {230 const { retentionMs } = msg.payload as { retentionMs: number };231 const purged = await vault.purgeExpiredTrash(retentionMs);232 return { ok: true, data: purged };233 }234 case "AUTOFILL_QUERY": {235 if (!vault.isUnlocked()) {236 return { ok: false, error: "VAULT_LOCKED" };237 }238 const { domain, includeAll, kind } = msg.payload as {239 domain: string;240 includeAll?: boolean;241 kind?: "login" | "card";242 };243 244 245 246 247 if (kind === "card") {248 const cards = vault.getEntries().filter((e) => e.type === "card");249 return {250 ok: true,251 data: cards.map((e) => ({252 id: e.id,253 title: e.cardNickname || e.title,254 cardholderName: e.cardholderName,255 last4: e.last4,256 expMonth: e.expMonth,257 expYear: e.expYear,258 network: e.network,259 })),260 };261 }262 const matches = includeAll263 ? vault264 .getEntries()265 .filter((entry) => entry.type !== "note" && entry.type !== "passkey" && entry.type !== "card")266 : vault.queryDomain(domain);267 return {268 ok: true,269 data: matches.map((e) =>270 includeAll271 ? {272 id: e.id,273 title: e.title,274 }275 : {276 id: e.id,277 title: e.title,278 username: e.username,279 url: e.url,280 hasTotp: Boolean(e.totp),281 }282 ),283 };284 }285 case "GET_ENTRY_FOR_FILL": {286 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };287 const { id } = msg.payload as { id: string };288 const entry = vault.getEntryById(id);289 if (!entry) return { ok: false, error: "entry not found" };290 if (entry.type === "passkey") return { ok: false, error: "passkey not fillable" };291 292 293 const { privateKeyJwk: _stripped, ...safeEntry } = entry;294 return { ok: true, data: safeEntry };295 }296 case "COPY": {297 const { text, label, alreadyWritten } = msg.payload as {298 text: string;299 label: string;300 alreadyWritten?: boolean;301 };302 await copyToClipboard(text, label, Boolean(alreadyWritten));303 return { ok: true };304 }305 case "CHECK_BREACH": {306 const { password } = msg.payload as { password: string };307 const count = await vault.checkBreach(password);308 return { ok: true, data: count };309 }310 case "CHECK_ALL_BREACHES": {311 const allEntries = vault.getEntries();312 const results: Record<string, number> = {};313 for (const entry of allEntries) {314 if (entry.type === "note") continue;315 if (!entry.password) continue;316 results[entry.id] = await vault.checkBreach(entry.password);317 318 await new Promise((r) => setTimeout(r, 200));319 }320 321 for (const [id, count] of Object.entries(results)) {322 const entry = vault.getEntryById(id);323 if (entry && (entry.breach ?? 0) !== count) {324 await vault.updateEntry(id, { breach: count > 0 ? count : undefined });325 }326 }327 return { ok: true, data: results };328 }329 case "GET_SETTINGS":330 return { ok: true, data: await vault.ensureSettingsLoaded() };331 case "UPDATE_SETTINGS": {332 const patch = msg.payload as Record<string, unknown>;333 await vault.updateSettings(patch as any);334 if (typeof patch.autoLockMinutes === "number") {335 await setupAutoLock();336 if (vault.isUnlocked()) touchAutoLock();337 }338 return { ok: true };339 }340 case "GET_PENDING_PASSKEY":341 return { ok: true, data: await getPendingPasskeyRequest() };342 case "APPROVE_PASSKEY": {343 const { requestId } = msg.payload as { requestId: number };344 await resolvePendingPasskeyRequest(requestId, true);345 return { ok: true };346 }347 case "DENY_PASSKEY": {348 const { requestId } = msg.payload as { requestId: number };349 await resolvePendingPasskeyRequest(requestId, false);350 return { ok: true };351 }352 case "GET_PASSKEYS":353 return {354 ok: true,355 data: vault.getEntries().filter((entry) => entry.type === "passkey"),356 };357 case "DELETE_PASSKEY": {358 const { id } = msg.payload as { id: string };359 await vault.deleteEntry(id);360 return { ok: true };361 }362 case "GET_PASSKEY_PROVIDER_STATE":363 return { ok: true, data: await getPasskeyProviderState() };364 case "SET_PASSKEY_PROVIDER_ENABLED": {365 const { enabled } = msg.payload as { enabled: boolean };366 367 368 if (enabled) {369 const gate = await requirePro("passkeys");370 if (!gate.allowed) return { ok: false, error: "pro_required" };371 }372 return { ok: true, data: await setPasskeyProviderEnabled(enabled) };373 }374 case "FIRE_CANARY_PING": {375 const { webhookUrl, entryTitle, trigger } = msg.payload as {376 webhookUrl: string;377 entryTitle: string;378 trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";379 };380 if (!isAllowedCanaryWebhookUrl(webhookUrl)) {381 return { ok: false, error: "invalid webhook url" };382 }383 const body = JSON.stringify({384 event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",385 entry: entryTitle,386 trigger,387 timestamp: new Date().toISOString(),388 source: "peach-extension",389 });390 if (trigger === "test") {391 392 393 394 395 396 try {397 const res = await fetch(webhookUrl, {398 method: "POST",399 headers: { "Content-Type": "application/json" },400 body,401 });402 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };403 } catch (err) {404 return { ok: false, error: err instanceof Error ? err.message : "network error" };405 }406 }407 408 fetch(webhookUrl, {409 method: "POST",410 headers: { "Content-Type": "application/json" },411 body,412 }).catch(() => {});413 return { ok: true };414 }415 case "ACTIVATE_LICENSE": {416 const { key } = msg.payload as { key: string };417 const licenseInfo = await activateLicense(key);418 return { ok: true, data: licenseInfo };419 }420 case "GET_LICENSE": {421 const licenseInfo = await getLicenseInfo();422 return { ok: true, data: licenseInfo };423 }424 case "GET_LICENSE_KEY": {425 const key = await getLicenseKey();426 return { ok: true, data: key };427 }428 case "DEACTIVATE_LICENSE": {429 await deactivateLicense();430 return { ok: true };431 }432 case "SHOULD_PLAY_CELEBRATION": {433 const should = await shouldPlayCelebration();434 return { ok: true, data: should };435 }436 case "MARK_CELEBRATION_PLAYED": {437 await markCelebrationPlayed();438 return { ok: true };439 }440 case "OPEN_PRO_GATE": {441 442 const { feature } = (msg.payload ?? {}) as { feature?: string };443 if (chrome.storage.session) {444 await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });445 }446 try { await chrome.action.openPopup(); } catch { }447 return { ok: true };448 }449 case "GET_IDENTITIES": {450 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };451 return { ok: true, data: vault.getIdentities() };452 }453 case "GET_ADVANCED_HEALTH_REPORT": {454 const gate = await requirePro("advanced_health");455 if (!gate.allowed) return { ok: false, error: "pro_required" };456 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };457 const report = await buildAdvancedHealthReport(vault.getEntries());458 return { ok: true, data: report };459 }460 case "INITIATE_PASSWORD_CHANGE": {461 const gate = await requirePro("smart_password_change");462 if (!gate.allowed) return { ok: false, error: "pro_required" };463 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };464 const { id } = msg.payload as { id: string };465 const entry = vault.getEntryById(id);466 if (!entry) return { ok: false, error: "entry not found" };467 const url = resolveResetUrl(entry);468 if (!url) return { ok: false, error: "no reset url" };469 try {470 await chrome.tabs.create({ url });471 return { ok: true, data: { url } };472 } catch (err) {473 return {474 ok: false,475 error: err instanceof Error ? err.message : "failed to open tab",476 };477 }478 }479 case "IS_DURESS_ENABLED": {480 const enabled = await vault.isDuressEnabled();481 return { ok: true, data: enabled };482 }483 case "SETUP_DURESS": {484 const { duressPassword } = msg.payload as { duressPassword: string };485 await vault.setupDuress(duressPassword);486 return { ok: true };487 }488 case "DISABLE_DURESS": {489 await vault.disableDuress();490 return { ok: true };491 }492 case "VERIFY_MAIN_PASSWORD": {493 const { password } = msg.payload as { password: string };494 const ok = await vault.verifyMainPassword(password);495 return { ok: true, data: ok };496 }497 case "CHANGE_PASSWORD": {498 const { currentPassword, newPassword } = msg.payload as {499 currentPassword: string;500 newPassword: string;501 };502 const changed = await vault.changePassword(currentPassword, newPassword);503 return changed504 ? { ok: true }505 : { ok: false, error: "incorrect current password" };506 }507 case "GET_SITE_PASSWORD_POLICY": {508 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });509 if (!tab?.id) return { ok: true, data: null };510 try {511 const response = await chrome.tabs.sendMessage(tab.id, {512 type: "GET_SITE_PASSWORD_POLICY",513 });514 return (response as MessageResponse) ?? { ok: true, data: null };515 } catch {516 return { ok: true, data: null };517 }518 }519 case "GET_SYNC_STATE": {520 const { getPairedDevices, getTrustedNetworks } = await import('./sync-storage');521 const { isOnTailscale } = await import('./sync-network');522 const { isProUser } = await import('./license');523 const pairedDevices = await getPairedDevices();524 const trustedNetworks = await getTrustedNetworks();525 const tailscale = await isOnTailscale();526 const pro = await isProUser();527 528 let status: SyncStatus = "offline";529 if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {530 if (pairedDevices.length === 0) {531 status = "no_peers";532 } else {533 status = "synced";534 }535 } else {536 status = "not_trusted";537 }538 539 return { ok: true, data: { pairedDevices, trustedNetworks, status, isPro: pro } };540 }541 case "TRIGGER_SYNC": {542 const gate = await requirePro("sync");543 if (!gate.allowed) return { ok: false, error: "pro_required" };544 return { ok: true };545 }546 case "START_PAIRING": {547 const gate = await requirePro("sync");548 if (!gate.allowed) return { ok: false, error: "pro_required" };549 return { ok: true };550 }551 case "CANCEL_PAIRING": {552 return { ok: true };553 }554 case "ACCEPT_PAIRING": {555 const gate = await requirePro("sync");556 if (!gate.allowed) return { ok: false, error: "pro_required" };557 return { ok: true };558 }559 case "REMOVE_PAIRED_DEVICE": {560 const { deviceId } = msg.payload as { deviceId: string };561 const { removePairedDevice } = await import('./sync-storage');562 await removePairedDevice(deviceId);563 return { ok: true };564 }565 case "REMOVE_TRUSTED_NETWORK": {566 const { ssid, bssid, isTailscale } = msg.payload as any;567 const { removeTrustedNetwork, getTrustedNetworks } = await import('./sync-storage');568 const networks = await getTrustedNetworks();569 const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 570 (isTailscale ? n.isTailscale : n.ssid === ssid) && 571 (!bssid || n.bssid === bssid)572 );573 if (network) {574 await removeTrustedNetwork(network.addedAt);575 }576 return { ok: true };577 }578 case "MARK_NETWORK_TRUSTED": {579 const { addTrustedNetwork } = await import('./sync-storage');580 const { getCurrentNetwork } = await import('./sync-network');581 const network = await getCurrentNetwork();582 await addTrustedNetwork({583 ssid: network.ssid,584 bssid: network.bssid,585 isTailscale: network.isTailscale,586 addedAt: Date.now(),587 });588 return { ok: true };589 }590 case "RECORD_MANUAL_SUMMON": {591 const { domain } = msg.payload as { domain: string };592 const settings = vault.getSettings();593 const existing = settings.manualSummonDomains?.[domain];594 const now = Date.now();595 await vault.updateSettings({596 manualSummonDomains: {597 ...settings.manualSummonDomains,598 [domain]: {599 firstSummonedAt: existing?.firstSummonedAt ?? now,600 summonCount: (existing?.summonCount ?? 0) + 1,601 lastSummonedAt: now,602 },603 },604 });605 return { ok: true };606 }607 case "DECLINE_CARD_SAVE": {608 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };609 await vault.declineCardSaveOffer(last4, expMonth, expYear);610 return { ok: true };611 }612 case "IS_CARD_DECLINED": {613 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };614 const declined = await vault.isCardDeclined(last4, expMonth, expYear);615 return { ok: true, data: declined };616 }617 case "DESTROY_VAULT": {618 619 620 621 622 623 vault.lock();624 await chrome.storage.local.clear();625 if (chrome.storage.session) {626 await chrome.storage.session.clear();627 }628 return { ok: true };629 }630631 case "SUBMIT_FEEDBACK": {632 const { category, message, version, browser } = msg.payload as {633 category: string;634 message: string;635 version: string;636 browser: string;637 };638 if (!message.trim()) return { ok: false, error: "message is empty" };639 if (!FEEDBACK_ENDPOINT) return { ok: true };640 if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {641 return { ok: false, error: "invalid feedback endpoint" };642 }643 try {644 const res = await fetch(FEEDBACK_ENDPOINT, {645 method: "POST",646 headers: { "Content-Type": "application/json" },647 body: JSON.stringify({ category, message, version, browser, source: "peach-extension" }),648 });649 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };650 } catch (err) {651 return { ok: false, error: err instanceof Error ? err.message : "network error" };652 }653 }654 default:655 return { ok: false, error: "unknown message type" };656 }657 } catch (err) {658 return {659 ok: false,660 error: err instanceof Error ? err.message : "operation failed",661 };662 }663}664665666function broadcastToContentScripts(message: { type: string }): void {667 chrome.tabs.query({}, (tabs) => {668 for (const tab of tabs) {669 if (tab.id !== undefined) {670 chrome.tabs.sendMessage(tab.id, message).catch(() => {671 672 });673 }674 }675 });676}677678function shouldRefreshAutoLock(type: MessageType): boolean {679 return (680 type !== "HAS_VAULT" &&681 type !== "IS_UNLOCKED" &&682 type !== "GET_VAULT_STATUS" &&683 type !== "LOCK" &&684 type !== "UPDATE_SETTINGS" &&685 type !== "GET_PENDING_PASSKEY" &&686 type !== "GET_PASSKEY_PROVIDER_STATE" &&687 type !== "DECLINE_CARD_SAVE" &&688 type !== "IS_CARD_DECLINED" &&689 type !== "SUBMIT_FEEDBACK"690 );691}692693function isAllowedCanaryWebhookUrl(value: string): boolean {694 try {695 const url = new URL(value);696 if (url.protocol !== "https:") return false;697698 const hostname = url.hostname.toLowerCase();699 if (700 hostname === "localhost" ||701 hostname.endsWith(".localhost") ||702 isPrivateIpv4(hostname) ||703 isPrivateIpv6(hostname)704 ) {705 return false;706 }707708 return true;709 } catch {710 return false;711 }712}713714function isPrivateIpv4(hostname: string): boolean {715 if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {716 return true;717 }718719 const octets = hostname.split(".").map((part) => Number(part));720 if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {721 return false;722 }723724 return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;725}726727function isPrivateIpv6(hostname: string): boolean {728 const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();729 return (730 normalized === "::1" ||731 normalized.startsWith("fc") ||732 normalized.startsWith("fd") ||733 normalized.startsWith("fe80:")734 );735}