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