123456789101112import { copyToClipboard } from "./clipboard";13import { getPendingSyncAlerts, clearPendingSyncAlerts } from "./sync-alerts";14import { extractDomain } from "../shared/domain";15import {16 getPendingVouchedDevice,17 listPendingVouchedDevices,18 removePendingVouchedDevice,19} from "./pending-vouched-devices";20import { setupAutoLock, touchAutoLock } from "./alarms";21import { runSelfTest } from "./self-test";22import * as vault from "./vault";23import {24 handleEstimateCodexPages,25 handleGetCodexVaultData,26 handleRestoreFromCodex,27} from "./codex-handlers";28import type { CodexRestorePayload } from "../shared/codex-types";29import {30 activateLicense,31 deactivateLicense,32 getLicenseInfo,33 getLicenseKey,34 isProUser,35 markCelebrationPlayed,36 shouldPlayCelebration,37} from "./license";38import {39 completeFirefoxPasskeyCreate,40 getPasskeyProviderState,41 getPendingPasskeyRequest,42 handleFirefoxPasskeyCreate,43 handleFirefoxPasskeyGet,44 handlePasskeyHasCredentials,45 initiateFirefoxPasskeyCreate,46 isPasskeyOfferDeclined,47 onVaultUnlocked,48 recordPasskeyOfferDecline,49 resolvePendingPasskeyRequest,50 setPasskeyProviderEnabled,51} from "./passkey";52import { buildAdvancedHealthReport } from "./health";53import { storageClear } from "../shared/storage";54import { findInvalidWords } from "../shared/recovery-words";55import type {56 Message,57 MessageResponse,58 MessageType,59 ProFeature,60 VaultEntry,61} from "../shared/types";62import { isPasskeyOnly } from "../shared/types";63import { generateTOTP } from "../crypto/totp";64import { isValidRpId } from "../shared/passkey-utils";65import { isNativePlatform, nativePlugin } from "../shared/native";66import { predictBatch as mlPredictBatch } from "./ml";67import type { MLFieldFeatures } from "../shared/ml";68import { buildLocalIdentity, setDeviceName as setDeviceNameV3 } from "./chirp-identity";69import {70 listPairedDevices as listPairedDevicesV3,71 removePairedDeviceById as removePairedDeviceByIdV3,72 upsertPairedDevice as upsertPairedDeviceV3,73} from "./paired-devices";747576777879declare const __PEACH_E2E_PASSKEYS__: boolean;80const PASSKEY_E2E_PRO_BYPASS = __PEACH_E2E_PASSKEYS__ === true;8182let nativeSyncRunnerBootstrapped = false;8384function unhexBytes(hex: string): Uint8Array {85 if (hex.length % 2 !== 0) throw new Error("invalid hex");86 const out = new Uint8Array(hex.length / 2);87 for (let i = 0; i < out.length; i++) {88 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);89 }90 return out;91}9293949596979899async function requirePro(100 feature: ProFeature,101 trigger: "user" | "background" = "user"102): Promise<{ allowed: boolean }> {103 if (PASSKEY_E2E_PRO_BYPASS && feature === "passkeys") return { allowed: true };104 if (await isProUser()) return { allowed: true };105106 if (trigger === "user") {107 108 chrome.runtime109 .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })110 .catch(() => {111 112 });113 } else {114 115 116 117 if (chrome.storage.session) {118 chrome.storage.session119 .set({ pendingProNotice: { feature, triggeredAt: Date.now() } })120 .catch(() => {});121 }122 }123124 return { allowed: false };125}126127128129130131132133134const FEEDBACK_ENDPOINT = "";135136137const CANARY_MIN_INTERVAL_MS = 60 * 60 * 1000; 138139const PRIVATE_IPV4_RANGES = [140 { prefix: "10." },141 { prefix: "127." },142 { prefix: "169.254." },143 { prefix: "192.168." },144];145146export function setupMessageHandler(): void {147 chrome.runtime.onMessage.addListener(148 (msg: unknown, sender, sendResponse) => {149 if (!isMessage(msg)) {150 sendResponse({ ok: false, error: "invalid_message" });151 return true;152 }153 handleMessage(msg, sender)154 .then(sendResponse)155 .catch((err) =>156 sendResponse({157 ok: false,158 error: err instanceof Error ? err.message : String(err),159 })160 );161 return true;162 }163 );164165 166 167 168 const reg = (globalThis as any).__peachRegisterMessageHandler;169 if (typeof reg === "function") {170 reg(async (m: unknown) => {171 if (!isMessage(m)) return { ok: false, error: "invalid_message" };172 try {173 return await handleMessage(m);174 } catch (err) {175 return {176 ok: false,177 error: err instanceof Error ? err.message : String(err),178 };179 }180 });181 }182 if (isNativePlatform()) {183 void bootstrapNativeSyncRunner();184 }185}186187async function bootstrapNativeSyncRunner(): Promise<void> {188 if (nativeSyncRunnerBootstrapped) return;189 nativeSyncRunnerBootstrapped = true;190 try {191 const { setSyncRunner, hydratePersistedSyncState } = await import("./sync-trigger");192 const { productionSyncRunner } = await import("./chirp-sync-runner");193 setSyncRunner(productionSyncRunner);194 await hydratePersistedSyncState();195 } catch (err) {196 nativeSyncRunnerBootstrapped = false;197 console.warn("[Peach] failed to bootstrap native sync runner:", err);198 }199}200201function isMessage(m: unknown): m is Message {202 return typeof m === "object" && m !== null && "type" in m;203}204205206207208209210211212213function attestedSenderOrigin(sender: Partial<chrome.runtime.MessageSender>): string | undefined {214 if (sender.origin) return sender.origin;215 if (sender.url) {216 try { return new URL(sender.url).origin; } catch { return undefined; }217 }218 return undefined;219}220221222223224225let restoreReady: Promise<void> = Promise.resolve();226227228export function setRestoreReady(p: Promise<unknown>): void {229 restoreReady = p.then(() => undefined, () => undefined);230}231232export async function handleMessage(233 msg: Message,234 sender: Partial<chrome.runtime.MessageSender> = {}235): Promise<MessageResponse> {236 try {237 238 239 240 241 242 243 await restoreReady;244 245 246 247 248 if (249 sender.id !== undefined &&250 sender.id !== chrome.runtime.id251 ) {252 return { ok: false, error: "invalid_sender" };253 }254255 if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {256 touchAutoLock();257 }258259 switch (msg.type) {260 case "CREATE_VAULT": {261 const { password } = msg.payload as { password: string };262 263 264 265 266 if (typeof password !== "string" || password.length === 0) {267 return { ok: false, error: "invalid_payload" };268 }269 await vault.createVault(password);270 await setupAutoLock();271 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });272 onVaultUnlocked();273 return { ok: true };274 }275 case "UNLOCK": {276 const { password } = msg.payload as { password: string };277 const ok = await vault.unlock(password);278 if (ok) {279 280 281 const kat = await runSelfTest();282 if (!kat.ok) {283 console.error("[Peach] Crypto self-test FAILED:", kat.error);284 await vault.lock();285 return { ok: false, error: "self_test_failed" };286 }287 await setupAutoLock();288 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });289 onVaultUnlocked();290 if (isNativePlatform()) await publishInMemoryVault(vault.getEntries());291 return { ok: true };292 }293 294 295 296 const failure = vault.consumeLastUnlockFailureReason();297 if (failure) {298 return {299 ok: false,300 error: "vault_corrupted",301 canRestoreFromHistory: true,302 } as MessageResponse;303 }304 return { ok: false };305 }306 case "VALIDATE_RECOVERY_WORDS": {307 const { words } = msg.payload as { words: string[] };308 const invalid = findInvalidWords(words);309 return { ok: true, data: { valid: invalid.length === 0, invalid } };310 }311 case "GET_RECOVERY_WORDS": {312 313 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };314 try {315 const words = await vault.getRecoveryWords();316 return { ok: true, data: words };317 } catch (e) {318 319 return { ok: false, error: "recovery_not_setup" };320 }321 }322 case "RECOVER_WITH_WORDS": {323 324 325 const { words, newPassword } = msg.payload as {326 words: string[];327 newPassword: string;328 };329 const stored = await vault.loadSlotA();330 if (!stored) return { ok: false, error: "no_vault" };331 if (!stored.recovery_salt || !stored.recovery_wrapped_root_key) {332 return { ok: false, error: "recovery_not_setup" };333 }334 335 try {336 await vault.unwrapRootKeyForRecovery(337 stored.recovery_wrapped_root_key,338 stored.recovery_nonce ?? "",339 stored.recovery_tag ?? "",340 stored.recovery_salt,341 words342 );343 } catch {344 return { ok: false, error: "invalid_recovery_words" };345 }346 347 const newSalt = vault.generateSalt();348 const newKey = await vault.deriveKey(newPassword, newSalt);349 350 351 await vault.rewrapRootKeyForPassword(newKey, newSalt);352 353 await vault.changePasswordWithNewKey(newSalt, newKey);354 await setupAutoLock();355 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });356 return { ok: true };357 }358 case "LOCK":359 vault.lock();360 if (isNativePlatform()) await clearInMemoryVault();361 return { ok: true };362 case "IS_UNLOCKED":363 return { ok: true, data: vault.isUnlocked() };364 case "GET_VAULT_STATUS":365 return { ok: true, unlocked: vault.isUnlocked() };366 case "HAS_VAULT":367 return { ok: true, data: await vault.hasVault() };368 case "GET_ENTRIES":369 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };370 return { ok: true, data: vault.getEntries() };371 case "ADD_ENTRY": {372 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };373 const payload = msg.payload;374 if (!payload || typeof payload !== "object" || Array.isArray(payload)) {375 return { ok: false, error: "invalid_payload" };376 }377 const entry = await vault.addEntry(payload as Parameters<typeof vault.addEntry>[0]);378 return { ok: true, data: entry };379 }380 case "BATCH_ADD_ENTRIES": {381 const payload = msg.payload as { entries?: unknown } | null | undefined;382 if (!payload || typeof payload !== "object" || Array.isArray(payload)) {383 return { ok: false, error: "invalid_payload" };384 }385 if (!Array.isArray(payload.entries)) {386 return { ok: false, error: "invalid_payload" };387 }388 const added = await vault.batchAddEntries(payload.entries as Parameters<typeof vault.batchAddEntries>[0]);389 return { ok: true, data: added };390 }391 case "RESTORE_ENTRIES": {392 const payload = msg.payload as393 | { entries?: unknown; mode?: unknown; settings?: unknown }394 | null395 | undefined;396 if (!payload || typeof payload !== "object" || Array.isArray(payload)) {397 return { ok: false, error: "invalid_payload" };398 }399 if (!Array.isArray(payload.entries)) {400 return { ok: false, error: "invalid_payload" };401 }402 if (payload.mode !== "replace" && payload.mode !== "merge") {403 return { ok: false, error: "invalid_payload" };404 }405 const summary = await vault.restoreEntries(406 payload.entries as Parameters<typeof vault.restoreEntries>[0],407 payload.mode408 );409 if (payload.settings && typeof payload.settings === "object" && !Array.isArray(payload.settings)) {410 await vault.updateSettings(payload.settings as vault.VaultSettings);411 }412 return { ok: true, data: summary };413 }414 case "CLEAR_VAULT_ENTRIES": {415 await vault.clearVaultEntries("REPLACE_ALL");416 return { ok: true };417 }418 case "GET_VAULT_HISTORY": {419 const snapshots = await vault.getVaultHistory();420 return { ok: true, data: snapshots };421 }422 case "RESTORE_VAULT_SNAPSHOT": {423 const { snapshotId } = msg.payload as { snapshotId: string };424 const result = await vault.restoreVaultSnapshot(snapshotId);425 if (!result.ok) return { ok: false, error: result.error };426 broadcastToContentScripts({ type: "VAULT_LOCKED" });427 return { ok: true };428 }429 case "COUNT_DUPLICATES": {430 return { ok: true, data: vault.countDuplicates() };431 }432 case "DEDUPLICATE_ENTRIES": {433 const removed = await vault.deduplicateEntries();434 return { ok: true, data: removed };435 }436 case "UPDATE_ENTRY": {437 const payload = msg.payload as { id?: unknown; patch?: unknown } | null | undefined;438 if (!payload || typeof payload !== "object" || Array.isArray(payload)) {439 return { ok: false, error: "invalid_payload" };440 }441 const { id, patch } = payload;442 if (typeof id !== "string" || !id || !patch || typeof patch !== "object" || Array.isArray(patch)) {443 return { ok: false, error: "invalid_payload" };444 }445 446 447 448 449 const incomingCanary = (patch as { canary?: unknown }).canary;450 if (incomingCanary && typeof incomingCanary === "object") {451 const existing = vault.getEntryById(id);452 if (!existing?.canary) {453 const gate = await requirePro("canary_entry");454 if (!gate.allowed) return { ok: false, error: "pro_required" };455 }456 }457 await vault.updateEntry(id, patch as Parameters<typeof vault.updateEntry>[1]);458 return { ok: true };459 }460 case "ADD_ADDITIONAL_URL": {461 const { id, url } = msg.payload as { id: string; url: string };462 const trimmed = typeof url === "string" ? url.trim() : "";463 if (!trimmed) return { ok: false, error: "url_required" };464 const existing = vault.getEntryById(id);465 if (!existing) return { ok: false, error: "entry_not_found" };466 if (existing.type !== "login") return { ok: false, error: "login_entries_only" };467 const merged = [...(existing.additionalUrls ?? []), trimmed];468 await vault.updateEntry(id, { additionalUrls: merged });469 return { ok: true };470 }471 case "DELETE_ENTRY": {472 const { id } = msg.payload as { id: string };473 await vault.deleteEntry(id);474 return { ok: true };475 }476 case "GET_DELETED_ENTRIES":477 return { ok: true, data: vault.getDeletedEntries() };478 case "RESTORE_ENTRY": {479 const { id } = msg.payload as { id: string };480 await vault.restoreEntry(id);481 return { ok: true };482 }483 case "PURGE_ENTRY": {484 const { id } = msg.payload as { id: string };485 await vault.purgeEntry(id);486 return { ok: true };487 }488 case "PURGE_EXPIRED_TRASH": {489 const { retentionMs } = msg.payload as { retentionMs: number };490 const purged = await vault.purgeExpiredTrash(retentionMs);491 return { ok: true, data: purged };492 }493 case "SAVE_NOTE": {494 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };495 const { id, title, body } = msg.payload as { id?: string; title: string; body: string };496 const now = Date.now();497 if (id) {498 const existing = vault.getEntryById(id);499 if (!existing) return { ok: false, error: "not_found" };500 await vault.updateEntry(id, { title: title || "Untitled", notes: body, modifiedAt: now });501 return { ok: true, data: vault.getEntryById(id) };502 } else {503 const entry = await vault.addEntry({504 type: "note",505 title: title || "Untitled",506 notes: body,507 createdAt: now,508 modifiedAt: now,509 } as any);510 return { ok: true, data: entry };511 }512 }513 case "DELETE_NOTE": {514 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };515 const { id } = msg.payload as { id: string };516 await vault.deleteEntry(id);517 return { ok: true };518 }519 case "GET_NOTES": {520 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };521 const notes = vault.getEntries().filter(522 (e) => e.type === "note" && !e.deletedAt523 );524 return { ok: true, data: notes } as MessageResponse;525 }526 case "SEARCH_VAULT": {527 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };528 529 530 const { query } = (msg.payload ?? {}) as { query?: unknown };531 if (typeof query !== "string") {532 return { ok: false, error: "invalid_payload" };533 }534 const q = query.toLowerCase();535 const searchResults = vault.getEntries().filter(536 (e) =>537 !e.deletedAt &&538 (e.title.toLowerCase().includes(q) ||539 e.notes?.toLowerCase().includes(q) ||540 e.username?.toLowerCase().includes(q))541 );542 return { ok: true, data: searchResults } as MessageResponse;543 }544 case "AUTOFILL_QUERY": {545 if (!vault.isUnlocked()) {546 return { ok: false, error: "vault_locked" };547 }548 let { includeAll } = msg.payload as { includeAll?: boolean };549 const { domain, kind, userGesture } = msg.payload as {550 domain: string;551 kind?: "login" | "card" | "totp";552 userGesture?: boolean;553 };554 555 556 557 558 559 560 561 if (includeAll && userGesture !== true) {562 includeAll = false;563 }564 565 566 567 568 if (kind === "card") {569 const cards = vault.getEntries().filter((e) => e.type === "card");570 return {571 ok: true,572 data: cards.map((e) => ({573 id: e.id,574 title: e.cardNickname || e.title,575 cardholderName: e.cardholderName,576 last4: e.last4,577 expMonth: e.expMonth,578 expYear: e.expYear,579 network: e.network,580 })),581 };582 }583 if (kind === "totp") {584 const matches = (585 includeAll586 ? vault.getEntries().filter((entry) => entry.type !== "note" && entry.type !== "card")587 : vault.queryDomain(domain)588 ).filter((entry) => !isPasskeyOnly(entry) && Boolean(entry.totp));589 return {590 ok: true,591 data: matches.map((e) => ({592 id: e.id,593 title: e.title,594 username: e.username,595 email: e.email,596 url: e.url,597 hasTotp: true,598 })),599 };600 }601 602 603 604 605 606 const matches = (607 includeAll608 ? vault609 .getEntries()610 .filter((entry) => entry.type !== "note" && entry.type !== "card")611 : vault.queryDomain(domain)612 ).filter((entry) => !isPasskeyOnly(entry));613 return {614 ok: true,615 data: matches.map((e) =>616 includeAll617 ? {618 id: e.id,619 title: e.title,620 621 622 623 624 625 username: e.username,626 email: e.email,627 url: e.url,628 hasPasskey: e.passkey !== undefined,629 }630 : {631 id: e.id,632 title: e.title,633 username: e.username,634 email: e.email,635 url: e.url,636 hasTotp: Boolean(e.totp),637 hasPasskey: e.passkey !== undefined,638 }639 ),640 };641 }642 case "GET_ENTRY_FOR_FILL": {643 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };644 const { id, explicitSummon } = msg.payload as {645 id: string;646 explicitSummon?: boolean;647 };648 const entry = vault.getEntryById(id);649 if (!entry) return { ok: false, error: "entry_not_found" };650 651 652 653 if (isPasskeyOnly(entry)) return { ok: false, error: "passkey_not_fillable" };654655 656 657 658 659 if (sender.tab !== undefined || sender.url !== undefined) {660 let senderDomain: string | null = null;661 try {662 const rawUrl = sender.tab?.url ?? sender.url ?? "";663 senderDomain = rawUrl ? extractDomain(new URL(rawUrl).hostname) : null;664 } catch {665 senderDomain = null;666 }667 if (!senderDomain) return { ok: false, error: "origin_unverified" };668669 const entryDomains = new Set<string>();670 const primary = extractDomain(entry.url ?? "");671 if (primary) entryDomains.add(primary);672 for (const extra of entry.additionalUrls ?? []) {673 const d = extractDomain(extra);674 if (d) entryDomains.add(d);675 }676677 678 679 680 if (explicitSummon !== true && !entryDomains.has(senderDomain)) {681 682 return { ok: false, error: "entry_not_found" };683 }684 }685 686 687 688 689 690 691 692 693 694 695 const { passkey: _strippedPasskey, totp: seed, passwordHistory: _ph, ...safeEntry } = entry;696 let totpCode: string | undefined;697 let totpRemaining: number | undefined;698 if (seed) {699 try {700 const generated = generateTOTP(seed);701 totpCode = generated.code;702 totpRemaining = generated.remaining;703 } catch {704 totpCode = undefined;705 }706 }707 return { ok: true, data: { ...safeEntry, totpCode, totpRemaining } };708 }709 710 711 712 713 714 715 case "GET_ENTRY_FOR_EDIT": {716 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };717 const { id } = msg.payload as { id: string };718 const entry = vault.getEntryById(id);719 if (!entry) return { ok: false, error: "entry_not_found" };720 const { passkey: _passkey, ...safeEntry } = entry;721 return { ok: true, data: safeEntry };722 }723 case "COPY": {724 725 726 727 const { text, label, alreadyWritten } = (msg.payload ?? {}) as {728 text?: unknown;729 label?: unknown;730 alreadyWritten?: unknown;731 };732 if (typeof text !== "string" || typeof label !== "string") {733 return { ok: false, error: "invalid_payload" };734 }735 await copyToClipboard(text, label, Boolean(alreadyWritten));736 return { ok: true };737 }738 case "CHECK_ALL_BREACHES": {739 const allEntries = vault.getEntries();740 const results: Record<string, number> = {};741 for (const entry of allEntries) {742 if (entry.type === "note") continue;743 if (!entry.password) continue;744 results[entry.id] = await vault.checkBreach(entry.password);745 746 await new Promise((r) => setTimeout(r, 200));747 }748 749 for (const [id, count] of Object.entries(results)) {750 const entry = vault.getEntryById(id);751 if (entry && (entry.breach ?? 0) !== count) {752 await vault.updateEntry(id, { breach: count > 0 ? count : undefined });753 }754 }755 return { ok: true, data: results };756 }757 case "GET_SETTINGS":758 return { ok: true, data: await vault.ensureSettingsLoaded() };759 case "UPDATE_SETTINGS": {760 const patch = msg.payload;761 if (!patch || typeof patch !== "object" || Array.isArray(patch)) {762 return { ok: false, error: "invalid_payload" };763 }764 await vault.updateSettings(patch as vault.VaultSettings);765 if (typeof (patch as Record<string, unknown>).autoLockMinutes === "number") {766 await setupAutoLock();767 if (vault.isUnlocked()) touchAutoLock();768 }769 return { ok: true };770 }771 case "GET_PENDING_PASSKEY":772 return { ok: true, data: await getPendingPasskeyRequest() };773 case "APPROVE_PASSKEY": {774 const { requestId } = msg.payload as { requestId: number };775 await resolvePendingPasskeyRequest(requestId, true);776 return { ok: true };777 }778 case "DENY_PASSKEY": {779 const { requestId } = msg.payload as { requestId: number };780 await resolvePendingPasskeyRequest(requestId, false);781 return { ok: true };782 }783 case "GET_PASSKEY_PROVIDER_STATE":784 return { ok: true, data: await getPasskeyProviderState() };785 case "SET_PASSKEY_PROVIDER_ENABLED": {786 const { enabled } = msg.payload as { enabled: boolean };787 788 789 if (enabled) {790 const gate = await requirePro("passkeys");791 if (!gate.allowed) return { ok: false, error: "pro_required" };792 }793 return { ok: true, data: await setPasskeyProviderEnabled(enabled) };794 }795 796 797 798 799 800 801 case "FIRE_CANARY_PING": {802 803 804 805 806 const { webhookUrl, entryTitle, trigger } = msg.payload as {807 webhookUrl: string;808 entryTitle: string;809 trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";810 };811 if (!isAllowedCanaryWebhookUrl(webhookUrl)) {812 return { ok: false, error: "invalid_webhook_url" };813 }814 815 816 817 818 819 820 821 if (trigger !== "test") {822 const rlKey = `canary-last-fetch:${webhookUrl}:${entryTitle}`;823 const stored = await chrome.storage.local.get(rlKey);824 const last = (stored as Record<string, unknown>)[rlKey];825 if (typeof last === "number" && Date.now() - last < CANARY_MIN_INTERVAL_MS) {826 return { ok: true, data: { rateLimited: true } };827 }828 await chrome.storage.local.set({ [rlKey]: Date.now() });829 }830 const body = JSON.stringify({831 event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",832 entry: entryTitle,833 trigger,834 timestamp: new Date().toISOString(),835 source: "peach-extension",836 });837 if (trigger === "test") {838 839 840 841 842 843 try {844 const res = await fetch(webhookUrl, {845 method: "POST",846 headers: { "Content-Type": "application/json" },847 body,848 });849 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };850 } catch (err) {851 return { ok: false, error: err instanceof Error ? err.message : "network_error" };852 }853 }854 855 fetch(webhookUrl, {856 method: "POST",857 headers: { "Content-Type": "application/json" },858 body,859 }).catch(() => {});860 return { ok: true };861 }862 case "ACTIVATE_LICENSE": {863 const { key } = msg.payload as { key: string };864 const licenseInfo = await activateLicense(key);865 return { ok: true, data: licenseInfo };866 }867 case "GET_LICENSE": {868 const licenseInfo = await getLicenseInfo();869 return { ok: true, data: licenseInfo };870 }871 case "GET_LICENSE_KEY": {872 const key = await getLicenseKey();873 return { ok: true, data: key };874 }875 case "DEACTIVATE_LICENSE": {876 await deactivateLicense();877 return { ok: true };878 }879 case "SHOULD_PLAY_CELEBRATION": {880 const should = await shouldPlayCelebration();881 return { ok: true, data: should };882 }883 case "MARK_CELEBRATION_PLAYED": {884 await markCelebrationPlayed();885 return { ok: true };886 }887 case "GET_CODEX_VAULT_DATA": {888 889 890 const gate = await requirePro("codex");891 if (!gate.allowed) return { ok: false, error: "pro_required" };892 return await handleGetCodexVaultData();893 }894 case "ESTIMATE_CODEX_PAGES": {895 const gate = await requirePro("codex");896 if (!gate.allowed) return { ok: false, error: "pro_required" };897 return await handleEstimateCodexPages();898 }899 case "RESTORE_FROM_CODEX": {900 901 902 903 const payload = msg.payload as CodexRestorePayload;904 return await handleRestoreFromCodex(payload);905 }906 case "OPEN_PRO_GATE": {907 908 const { feature } = (msg.payload ?? {}) as { feature?: string };909 if (chrome.storage.session) {910 await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });911 }912 try { await chrome.action.openPopup(); } catch { }913 return { ok: true };914 }915 case "OPEN_POPUP": {916 917 918 919 920 921 922 923 924 925 if (chrome.storage.session) {926 await chrome.storage.session.set({ peach_inline_unlock: true });927 }928 try { await chrome.action.openPopup(); } catch { }929 return { ok: true };930 }931 case "GET_IDENTITIES": {932 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };933 return { ok: true, data: vault.getIdentities() };934 }935 case "TOUCH_AUTO_LOCK": {936 937 938 return { ok: true };939 }940 case "GET_ADVANCED_HEALTH_REPORT": {941 const gate = await requirePro("advanced_health");942 if (!gate.allowed) return { ok: false, error: "pro_required" };943 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };944 const report = await buildAdvancedHealthReport(vault.getEntries());945 return { ok: true, data: report };946 }947 case "IS_DURESS_ENABLED": {948 const enabled = await vault.isDuressEnabled();949 return { ok: true, data: enabled };950 }951 case "SETUP_DURESS": {952 953 954 955 956 const gate = await requirePro("duress_vault");957 if (!gate.allowed) return { ok: false, error: "pro_required" };958 959 960 961 if (!vault.isUnlocked()) return { ok: false, error: "session_expired" };962 const { duressPassword } = msg.payload as { duressPassword: string };963 await vault.setupDuress(duressPassword);964 return { ok: true };965 }966 case "DISABLE_DURESS": {967 await vault.disableDuress();968 return { ok: true };969 }970 case "VERIFY_MAIN_PASSWORD": {971 const { password } = msg.payload as { password: string };972 const ok = await vault.verifyMainPassword(password);973 return { ok: true, data: ok };974 }975 case "CHANGE_PASSWORD": {976 const { currentPassword, newPassword } = msg.payload as {977 currentPassword: string;978 newPassword: string;979 };980 const changed = await vault.changePassword(currentPassword, newPassword);981 return changed982 ? { ok: true }983 : { ok: false, error: "incorrect_current_password" };984 }985 case "GET_SITE_PASSWORD_POLICY": {986 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });987 if (!tab?.id) return { ok: true, data: null };988 try {989 const response = await chrome.tabs.sendMessage(tab.id, {990 type: "GET_SITE_PASSWORD_POLICY",991 });992 return (response as MessageResponse) ?? { ok: true, data: null };993 } catch {994 return { ok: true, data: null };995 }996 }997 case "RECORD_MANUAL_SUMMON": {998 const { domain } = msg.payload as { domain: string };999 const settings = vault.getSettings();1000 const existing = settings.manualSummonDomains?.[domain];1001 const now = Date.now();1002 await vault.updateSettings({1003 manualSummonDomains: {1004 ...settings.manualSummonDomains,1005 [domain]: {1006 firstSummonedAt: existing?.firstSummonedAt ?? now,1007 summonCount: (existing?.summonCount ?? 0) + 1,1008 lastSummonedAt: now,1009 },1010 },1011 });1012 return { ok: true };1013 }1014 case "DECLINE_CARD_SAVE": {1015 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1016 await vault.declineCardSaveOffer(last4, expMonth, expYear);1017 return { ok: true };1018 }1019 case "IS_CARD_DECLINED": {1020 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1021 const declined = await vault.isCardDeclined(last4, expMonth, expYear);1022 return { ok: true, data: declined };1023 }1024 case "INITIATE_FIREFOX_PASSKEY_CREATE": {1025 const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {1026 rpId?: string;1027 rpName?: string;1028 userName?: string;1029 userDisplayName?: string;1030 };1031 if (!rpId || !userName) return { ok: false, error: "rpid_and_username_required" };1032 return await initiateFirefoxPasskeyCreate({1033 rpId,1034 rpName: rpName ?? rpId,1035 userName,1036 userDisplayName,1037 });1038 }10391040 case "COMPLETE_FIREFOX_PASSKEY_CREATE": {1041 const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;1042 if (!payload) return { ok: false, error: "missing_payload" };1043 return await completeFirefoxPasskeyCreate(payload);1044 }10451046 case "PASSKEY_OFFER_DECLINE": {1047 const { domain } = (msg.payload ?? {}) as { domain?: string };1048 if (!domain) return { ok: false, error: "domain_required" };1049 await recordPasskeyOfferDecline(domain);1050 return { ok: true };1051 }10521053 case "GET_PASSKEY_OFFER_DECLINED": {1054 const { domain } = (msg.payload ?? {}) as { domain?: string };1055 if (!domain) return { ok: false, error: "domain_required" };1056 return { ok: true, data: await isPasskeyOfferDeclined(domain) };1057 }10581059 case "PASSKEY_HAS_CREDENTIALS": {1060 const payload = msg.payload as {1061 rpId?: string;1062 allowCredentials?: Array<{ id: string; type?: string }>;1063 } | undefined;1064 if (!payload?.rpId) return { ok: false, error: "rpid_required" };1065 return await handlePasskeyHasCredentials({1066 rpId: payload.rpId,1067 allowCredentials: payload.allowCredentials ?? [],1068 }, attestedSenderOrigin(sender));1069 }10701071 case "PASSKEY_CREATE": {1072 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;1073 if (!payload?.rpId || !payload?.userName) {1074 return { ok: false, error: "rpid_and_username_required" };1075 }1076 return await handleFirefoxPasskeyCreate(payload, attestedSenderOrigin(sender));1077 }10781079 case "PASSKEY_GET": {1080 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;1081 if (!payload?.rpId) return { ok: false, error: "rpid_required" };1082 return await handleFirefoxPasskeyGet(payload, attestedSenderOrigin(sender));1083 }10841085 case "PASSKEY_AUTH_RESPONSE": {1086 const { requestId, approved } = (msg.payload ?? {}) as {1087 requestId?: string | number;1088 approved?: boolean;1089 };1090 if (requestId === undefined || requestId === null || typeof approved !== 'boolean') {1091 return { ok: false, error: 'requestId and approved required' };1092 }1093 await resolvePendingPasskeyRequest(Number(requestId), approved);1094 return { ok: true };1095 }10961097 case "GET_PASSKEY_FOR_DOMAIN": {1098 const { domain } = (msg.payload ?? {}) as { domain?: string };1099 if (!domain) return { ok: false, error: "domain_required" };1100 1101 1102 1103 const origin = attestedSenderOrigin(sender);1104 if (!origin || !isValidRpId(domain, origin)) return { ok: true, data: null };1105 if (!vault.isUnlocked()) return { ok: true, data: null };1106 const match = vault1107 .getEntries()1108 .find((entry) => entry.passkey?.rpId === domain);1109 if (!match || !match.passkey) return { ok: true, data: null };1110 1111 1112 return {1113 ok: true,1114 data: {1115 rpId: match.passkey.rpId,1116 userName: match.passkey.userName,1117 browser: match.passkey.browser,1118 },1119 };1120 }11211122 case "DESTROY_VAULT": {1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 const preserved = await chrome.storage.local.get([1134 "peach_license",1135 "peach_pro_celebrated",1136 ]);1137 vault.lock();1138 await storageClear();1139 await vault.clearVaultHistory();1140 const toRestore: Record<string, unknown> = {};1141 if (preserved.peach_license !== undefined) toRestore.peach_license = preserved.peach_license;1142 if (preserved.peach_pro_celebrated !== undefined) toRestore.peach_pro_celebrated = preserved.peach_pro_celebrated;1143 if (Object.keys(toRestore).length > 0) {1144 await chrome.storage.local.set(toRestore);1145 }1146 if (chrome.storage.session) {1147 await chrome.storage.session.clear();1148 }1149 return { ok: true };1150 }11511152 case "SUBMIT_FEEDBACK": {1153 const { category, message, version, browser } = msg.payload as {1154 category: string;1155 message: string;1156 version: string;1157 browser: string;1158 };1159 if (!message.trim()) return { ok: false, error: "message_empty" };1160 if (!FEEDBACK_ENDPOINT) return { ok: true };1161 if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {1162 return { ok: false, error: "invalid_feedback_endpoint" };1163 }1164 try {1165 const res = await fetch(FEEDBACK_ENDPOINT, {1166 method: "POST",1167 headers: { "Content-Type": "application/json" },1168 body: JSON.stringify({ category, message, version, browser, source: "peach-extension" }),1169 });1170 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };1171 } catch (err) {1172 return { ok: false, error: err instanceof Error ? err.message : "network_error" };1173 }1174 }1175 1176 case "UNLOCK_WITH_KEY": {1177 const { masterKeyHex } = msg.payload as { masterKeyHex: string };1178 if (!isNativePlatform()) {1179 return { ok: false, error: "desktop_only_uses_password" };1180 }1181 try {1182 const result = await vault.unlockWithKey(masterKeyHex);1183 if (result.ok) {1184 onVaultUnlocked();1185 await publishInMemoryVault(result.entries);1186 }1187 return { ok: result.ok, data: result.ok ? { entries: result.entries } : undefined, error: result.ok ? undefined : result.error };1188 } catch {1189 return { ok: false, error: "invalid_key" };1190 }1191 }1192 1193 case "GET_DERIVED_KEY": {1194 if (!isNativePlatform()) {1195 return { ok: false, error: "android_only" };1196 }1197 if (!vault.isUnlocked()) {1198 return { ok: false, error: "vault_locked" };1199 }1200 return { ok: true, data: { masterKeyHex: vault.getDerivedKeyHex() } };1201 }1202 case "GET_IDENTITY_SUGGESTIONS": {1203 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1204 const entries = vault.getEntries();1205 const suggestions = buildIdentitySuggestions(entries);1206 return { ok: true, data: { suggestions } };1207 }1208 case "POPULATE_INLINE_VAULT": {1209 if (!isNativePlatform()) return { ok: false };1210 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1211 const entries = vault.getEntries();1212 await publishInMemoryVault(entries);1213 return { ok: true };1214 }1215 case "ML_INIT":1216 return { ok: true };12171218 case "ML_PREDICT_BATCH": {1219 1220 const { featuresArray } = (msg.payload ?? {}) as { featuresArray?: unknown };1221 if (!Array.isArray(featuresArray)) {1222 return { ok: false, error: "invalid_payload" };1223 }1224 return { ok: true, data: mlPredictBatch(featuresArray as MLFieldFeatures[]) };1225 }12261227 1228 1229 1230 1231 case "CHIRP_GET_LOCAL_IDENTITY": {1232 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1233 const proCheck = await requirePro("sync");1234 if (!proCheck.allowed) return { ok: false, error: "pro_required" };1235 1236 1237 const masterKeyHex = vault.getRootKeyHex();1238 if (!masterKeyHex) return { ok: false, error: "vault_locked" };1239 const masterKey = unhexBytes(masterKeyHex);1240 try {1241 const identity = await buildLocalIdentity(masterKey);1242 return { ok: true, data: identity };1243 } finally {1244 masterKey.fill(0);1245 }1246 }12471248 case "CHIRP_COMPLETE_PAIRING": {1249 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1250 const proCheck = await requirePro("sync");1251 if (!proCheck.allowed) return { ok: false, error: "pro_required" };1252 const paired = (msg.payload as { paired?: unknown })?.paired;1253 if (typeof paired !== "object" || paired === null) {1254 return { ok: false, error: "invalid_payload" };1255 }1256 const candidate = paired as Record<string, unknown>;1257 if (1258 typeof candidate.deviceId !== "string" || !candidate.deviceId ||1259 typeof candidate.deviceName !== "string" || !candidate.deviceName ||1260 typeof candidate.deviceType !== "string" ||1261 typeof candidate.longTermPublicKeyHex !== "string" ||1262 !/^[0-9a-f]{64}$/.test(candidate.longTermPublicKeyHex) ||1263 (1264 candidate.signingPublicKeyHex !== undefined &&1265 (1266 typeof candidate.signingPublicKeyHex !== "string" ||1267 !/^[0-9a-f]{64}$/.test(candidate.signingPublicKeyHex)1268 )1269 ) ||1270 typeof candidate.pairedAt !== "number"1271 ) {1272 return { ok: false, error: "invalid_paired_device" };1273 }1274 await upsertPairedDeviceV3(candidate as unknown as import("../shared/sync-types").PairedDevice);1275 return { ok: true };1276 }12771278 case "CHIRP_LIST_PAIRED_DEVICES": {1279 const devices = await listPairedDevicesV3();1280 return { ok: true, data: { devices } };1281 }12821283 case "CHIRP_UNPAIR_DEVICE": {1284 const { deviceId } = (msg.payload ?? {}) as { deviceId?: string };1285 if (typeof deviceId !== "string" || !deviceId) {1286 return { ok: false, error: "invalid_payload" };1287 }1288 await removePairedDeviceByIdV3(deviceId);1289 return { ok: true };1290 }12911292 case "CHIRP_RENAME_LOCAL_DEVICE": {1293 const { name } = (msg.payload ?? {}) as { name?: string };1294 if (typeof name !== "string") return { ok: false, error: "invalid_payload" };1295 await setDeviceNameV3(name);1296 return { ok: true };1297 }12981299 case "TRIGGER_SYNC": {1300 const payload = (msg.payload ?? {}) as {1301 reason?: "vault_change" | "foreground" | "manual" | "mdns";1302 force?: boolean;1303 deviceId?: string;1304 discoveredIp?: string;1305 discoveredPort?: number;1306 };1307 const reason = payload.reason ?? "foreground";13081309 1310 1311 1312 if (1313 reason === "mdns" &&1314 typeof payload.deviceId === "string" &&1315 typeof payload.discoveredIp === "string"1316 ) {1317 const { findPairedDeviceById, setLastKnownIp } = await import("./paired-devices");1318 const device = await findPairedDeviceById(payload.deviceId);1319 if (device?.longTermPublicKeyHex) {1320 await setLastKnownIp(device.longTermPublicKeyHex, payload.discoveredIp).catch(() => undefined);1321 }1322 }13231324 const { triggerSyncIfReachable } = await import("./sync-trigger");1325 void triggerSyncIfReachable({ reason, force: payload.force === true });1326 return { ok: true };1327 }13281329 case "GET_SYNC_STATE": {1330 const { getSyncState } = await import("./sync-trigger");1331 return { ok: true, data: getSyncState() };1332 }13331334 case "GET_SYNC_ALERTS": {1335 const { consume } = (msg.payload ?? {}) as { consume?: boolean };1336 const alerts = await getPendingSyncAlerts();1337 1338 1339 1340 if (consume) await clearPendingSyncAlerts();1341 return { ok: true, data: alerts };1342 }13431344 case "GET_PENDING_VOUCHED_DEVICES": {1345 return { ok: true, data: await listPendingVouchedDevices() };1346 }1347 case "CONFIRM_VOUCHED_DEVICE": {1348 const { deviceId } = msg.payload as { deviceId: string };1349 const pending = await getPendingVouchedDevice(deviceId);1350 if (!pending) return { ok: false, error: "not_pending" };1351 await upsertPairedDeviceV3({1352 deviceId: pending.deviceId,1353 deviceName: pending.deviceName,1354 deviceType:1355 pending.deviceType as import("../shared/sync-types").PairedDevice["deviceType"],1356 longTermPublicKeyHex: pending.longTermPublicKeyHex,1357 signingPublicKeyHex: pending.signingPublicKeyHex,1358 wsPort: pending.wsPort,1359 pairedAt: pending.pairedAt,1360 trustSource: "vouched",1361 });1362 await removePendingVouchedDevice(deviceId);1363 return { ok: true };1364 }1365 case "REJECT_VOUCHED_DEVICE": {1366 const { deviceId } = msg.payload as { deviceId: string };1367 await removePendingVouchedDevice(deviceId);1368 return { ok: true };1369 }1370 case "ROTATE_SYNC_IDENTITY": {1371 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1372 const result = await vault.rotateSyncIdentity();1373 return result.ok ? { ok: true } : { ok: false, error: result.error };1374 }13751376 default:1377 return { ok: false, error: "unknown_message_type" };1378 }1379 } catch (err) {1380 return {1381 ok: false,1382 error: err instanceof Error ? err.message : "operation_failed",1383 };1384 }1385}13861387138813891390async function publishInMemoryVault(entries: VaultEntry[]): Promise<void> {1391 const plugin = nativePlugin<{1392 setEntries?: (args: {1393 entries: Array<{1394 id: string;1395 title: string;1396 username: string;1397 email: string;1398 password: string;1399 url: string;1400 totp: string;1401 additionalUrls: string[];1402 }>;1403 }) => Promise<void>;1404 }>("InMemoryVaultPlugin");1405 if (!plugin?.setEntries) return;1406 await plugin.setEntries({1407 entries: entries.map((e) => ({1408 id: e.id,1409 title: e.title ?? "",1410 username: e.username ?? "",1411 email: (e as VaultEntry & { email?: string }).email ?? "",1412 password: e.password ?? "",1413 url: e.url ?? "",1414 totp: (e as VaultEntry & { totp?: string }).totp ?? "",1415 additionalUrls: (e as VaultEntry & { additionalUrls?: string[] }).additionalUrls ?? [],1416 })),1417 });1418}141914201421async function clearInMemoryVault(): Promise<void> {1422 const plugin = nativePlugin<{ clearEntries?: () => Promise<void> }>("InMemoryVaultPlugin");1423 if (!plugin?.clearEntries) return;1424 await plugin.clearEntries();1425}14261427142814291430function buildIdentitySuggestions(1431 entries: VaultEntry[],1432): Array<{ email: string; username: string; count: number }> {1433 const usernameCounts = new Map<string, number>();1434 const usernameEmail = new Map<string, string>();1435 for (const entry of entries) {1436 const username = entry.username ?? "";1437 const email = (entry as VaultEntry & { email?: string }).email ?? "";1438 if (!username && !email) continue;1439 const key = username || email;1440 usernameCounts.set(key, (usernameCounts.get(key) ?? 0) + 1);1441 if (!usernameEmail.has(key)) usernameEmail.set(key, email);1442 }1443 return Array.from(usernameCounts.entries())1444 .sort((a, b) => b[1] - a[1])1445 .map(([username, count]) => ({1446 email: usernameEmail.get(username) ?? "",1447 username,1448 count,1449 }));1450}145114521453function broadcastToContentScripts(message: { type: string }): void {1454 if (!chrome.tabs?.query) return;1455 chrome.tabs.query({}, (tabs) => {1456 for (const tab of tabs) {1457 if (tab.id !== undefined) {1458 chrome.tabs.sendMessage(tab.id, message).catch(() => {1459 1460 });1461 }1462 }1463 });1464}14651466function shouldRefreshAutoLock(type: MessageType): boolean {1467 return (1468 type !== "HAS_VAULT" &&1469 type !== "IS_UNLOCKED" &&1470 type !== "GET_VAULT_STATUS" &&1471 type !== "LOCK" &&1472 type !== "UPDATE_SETTINGS" &&1473 type !== "GET_PENDING_PASSKEY" &&1474 type !== "GET_PASSKEY_PROVIDER_STATE" &&1475 type !== "DECLINE_CARD_SAVE" &&1476 type !== "IS_CARD_DECLINED" &&1477 type !== "SUBMIT_FEEDBACK"1478 );1479}14801481function isAllowedCanaryWebhookUrl(value: string): boolean {1482 try {1483 const url = new URL(value);1484 if (url.protocol !== "https:") return false;14851486 const hostname = url.hostname.toLowerCase();1487 if (1488 hostname === "localhost" ||1489 hostname.endsWith(".localhost") ||1490 isPrivateIpv4(hostname) ||1491 isPrivateIpv6(hostname)1492 ) {1493 return false;1494 }14951496 return true;1497 } catch {1498 return false;1499 }1500}15011502function isPrivateIpv4(hostname: string): boolean {1503 if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {1504 return true;1505 }15061507 const octets = hostname.split(".").map((part) => Number(part));1508 if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {1509 return false;1510 }15111512 return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;1513}15141515function isPrivateIpv6(hostname: string): boolean {1516 const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();1517 return (1518 normalized === "::1" ||1519 normalized.startsWith("fc") ||1520 normalized.startsWith("fd") ||1521 normalized.startsWith("fe80:")1522 );1523}