123456789101112import { copyToClipboard, getEffectiveClearSeconds } 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 {21 clearRecoveryConfirmPending,22 isRecoveryConfirmPending,23 setRecoveryConfirmPending,24} from "./recovery-pending";25import { setupAutoLock, touchAutoLock } from "./alarms";26import { runSelfTest } from "./self-test";27import * as vault from "./vault";28import {29 handleEstimateCodexPages,30 handleGetCodexVaultData,31 handleRestoreFromCodex,32} from "./codex-handlers";33import type { CodexRestorePayload } from "../shared/codex-types";34import {35 activateLicense,36 deactivateLicense,37 getLicenseInfo,38 getLicenseKey,39 isProUser,40 markCelebrationPlayed,41 shouldPlayCelebration,42} from "./license";43import {44 completeFirefoxPasskeyCreate,45 getPasskeyProviderState,46 getPendingPasskeyRequest,47 handleFirefoxPasskeyCreate,48 handleFirefoxPasskeyGet,49 handlePasskeyHasCredentials,50 initiateFirefoxPasskeyCreate,51 isPasskeyOfferDeclined,52 onVaultUnlocked,53 recordPasskeyOfferDecline,54 resolvePendingPasskeyRequest,55 setPasskeyProviderEnabled,56} from "./passkey";57import { buildAdvancedHealthReport } from "./health";58import { storageClear } from "../shared/storage";59import { findInvalidWords } from "../shared/recovery-words";60import type {61 Message,62 MessageResponse,63 MessageType,64 ProFeature,65 VaultEntry,66} from "../shared/types";67import { isPasskeyOnly } from "../shared/types";68import { generateTOTP } from "../crypto/totp";69import { isValidRpId } from "../shared/passkey-utils";70import { predictBatch as mlPredictBatch } from "./ml";71import type { MLFieldFeatures } from "../shared/ml";72import {73 buildLocalIdentity,74 getOrCreateDeviceId,75 setDeviceName as setDeviceNameV3,76} from "./device-identity";77import {78 isDeviceRevoked,79 listPairedDevices as listPairedDevicesV3,80 renamePairedDevice as renamePairedDeviceV3,81 revokeDeviceById as revokeDeviceByIdV3,82 upsertPairedDevice as upsertPairedDeviceV3,83} from "./paired-devices";84import { dropPairSecret } from "./pair-secrets";8586878889909192import {93 cancelRelayAdoptionSource,94 isRelayAdoptionSourceInFlight,95 resolveSourceSasConfirmation,96 startRelayAdoptionReceiver,97 startRelayAdoptionSource,98} from "./relay-adoption-bg";99import {100 getSyncSettings,101 setRelayUrl,102 setSyncMode,103 type SyncMode,104} from "./sync-settings";105import {106 getSyncState,107 triggerSyncIfReachable,108} from "./sync-trigger";109import { isMessageSenderAllowed } from "./message-policy";110111const SUMMON_CAPABILITY_TTL_MS = 30_000;112type SummonCapability = { tabId: number; frameId: number; origin: string; operation: "inventory" | "fill"; expiresAt: number };113const summonCapabilities = new Map<string, SummonCapability>();114115export function createSummonCapability(tabId: number, origin: string, frameId = 0): string {116 const token = crypto.randomUUID();117 summonCapabilities.set(token, { tabId, frameId, origin, operation: "inventory", expiresAt: Date.now() + SUMMON_CAPABILITY_TTL_MS });118 return token;119}120121function consumeSummonCapability(token: unknown, sender: Partial<chrome.runtime.MessageSender>, operation: "inventory" | "fill"): boolean {122 if (typeof token !== "string") return false;123 const cap = summonCapabilities.get(token);124 if (!cap) return false;125 if (cap.expiresAt <= Date.now()) {126 summonCapabilities.delete(token);127 return false;128 }129 if (sender.tab?.id !== cap.tabId || (sender.frameId ?? 0) !== cap.frameId || attestedSenderOrigin(sender) !== cap.origin) return false;130 if (cap.operation !== operation) return false;131 if (operation === "inventory") cap.operation = "fill";132 else summonCapabilities.delete(token);133 return true;134}135136137138139140declare const __PEACH_E2E_PASSKEYS__: boolean;141const PASSKEY_E2E_PRO_BYPASS = __PEACH_E2E_PASSKEYS__ === true;142143function unhexBytes(hex: string): Uint8Array {144 if (hex.length % 2 !== 0) throw new Error("invalid hex");145 const out = new Uint8Array(hex.length / 2);146 for (let i = 0; i < out.length; i++) {147 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);148 }149 return out;150}151152153154155156157158async function requirePro(159 feature: ProFeature,160 trigger: "user" | "background" = "user"161): Promise<{ allowed: boolean }> {162 if (PASSKEY_E2E_PRO_BYPASS && feature === "passkeys") return { allowed: true };163 if (await isProUser()) return { allowed: true };164165 if (trigger === "user") {166 167 chrome.runtime168 .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })169 .catch(() => {170 171 });172 } else {173 174 175 176 if (chrome.storage.session) {177 chrome.storage.session178 .set({ pendingProNotice: { feature, triggeredAt: Date.now() } })179 .catch(() => {});180 }181 }182183 return { allowed: false };184}185186187const CANARY_MIN_INTERVAL_MS = 60 * 60 * 1000; 188189const PRIVATE_IPV4_RANGES = [190 { prefix: "10." },191 { prefix: "127." },192 { prefix: "169.254." },193 { prefix: "192.168." },194];195196export function setupMessageHandler(): void {197 chrome.runtime.onMessage.addListener(198 (msg: unknown, sender, sendResponse) => {199 if (!isMessage(msg)) {200 sendResponse({ ok: false, error: "invalid_message" });201 return true;202 }203 handleMessage(msg, sender)204 .then(sendResponse)205 .catch((err) =>206 sendResponse({207 ok: false,208 error: err instanceof Error ? err.message : String(err),209 })210 );211 return true;212 }213 );214}215216function isMessage(m: unknown): m is Message {217 return typeof m === "object" && m !== null && "type" in m;218}219220221222223224225226227228function attestedSenderOrigin(sender: Partial<chrome.runtime.MessageSender>): string | undefined {229 if (sender.origin) return sender.origin;230 if (sender.url) {231 try { return new URL(sender.url).origin; } catch { return undefined; }232 }233 return undefined;234}235236function isExtensionPageSender(sender: Partial<chrome.runtime.MessageSender>): boolean {237 return sender.url?.startsWith("chrome-extension://") === true ||238 sender.url?.startsWith("moz-extension://") === true;239}240241242function isPageSender(sender: Partial<chrome.runtime.MessageSender>): boolean {243 return !isExtensionPageSender(sender) &&244 (sender.tab !== undefined || sender.url !== undefined);245}246247function entryDomains(url: unknown, additionalUrls: unknown): Set<string> {248 const domains = new Set<string>();249 const primary = typeof url === "string" ? extractDomain(url) : null;250 if (primary) domains.add(primary);251 if (Array.isArray(additionalUrls)) {252 for (const extra of additionalUrls) {253 if (typeof extra !== "string") continue;254 const domain = extractDomain(extra);255 if (domain) domains.add(domain);256 }257 }258 return domains;259}260261262263264265266267268269270271272273274275276277278279280function senderMayBindEntryDomains(281 sender: Partial<chrome.runtime.MessageSender>,282 domains: Set<string>,283): boolean {284 if (isExtensionPageSender(sender)) return true;285 const domain = attestedSenderDomain(sender);286 if (domain === null) return false;287 for (const candidate of domains) {288 if (candidate !== domain) return false;289 }290 return true;291}292293function senderMayMutateEntryDomains(294 sender: Partial<chrome.runtime.MessageSender>,295 before: Set<string>,296 after: Set<string>,297): boolean {298 if (isExtensionPageSender(sender)) return true;299 const domain = attestedSenderDomain(sender);300 if (domain === null || !after.has(domain)) return false;301 for (const kept of after) {302 if (!before.has(kept)) return false;303 }304 return true;305}306307308309310311function attestedSenderDomain(sender: Partial<chrome.runtime.MessageSender>): string | null {312 if (sender.tab === undefined && sender.url === undefined) return null;313 const origin = attestedSenderOrigin(sender);314 if (!origin) return null;315 try { return extractDomain(new URL(origin).hostname); } catch { return null; }316}317318function patchChangesEntryOrigin(patch: object): boolean {319 return Object.hasOwn(patch, "url") || Object.hasOwn(patch, "additionalUrls");320}321322323function patchedUrlShape(324 patch: object,325 existing: Pick<VaultEntry, "url" | "additionalUrls">,326): { url: unknown; additionalUrls: unknown } {327 const candidate = patch as { url?: unknown; additionalUrls?: unknown };328 return {329 url: Object.hasOwn(patch, "url") ? candidate.url : existing.url,330 additionalUrls: Object.hasOwn(patch, "additionalUrls")331 ? candidate.additionalUrls332 : existing.additionalUrls,333 };334}335336337338339let restoreReady: Promise<void> = Promise.resolve();340341342export function setRestoreReady(p: Promise<unknown>): void {343 restoreReady = p.then(() => undefined, () => undefined);344}345346export async function handleMessage(347 msg: Message,348 sender: Partial<chrome.runtime.MessageSender> = {}349): Promise<MessageResponse> {350 try {351 352 353 354 355 356 357 await restoreReady;358 359 360 361 362 if (363 sender.id !== undefined &&364 sender.id !== chrome.runtime.id365 ) {366 return { ok: false, error: "invalid_sender" };367 }368369 370 371 372 373 374 375 376 const extensionOrigin = sender.url && typeof chrome.runtime.getURL === "function"377 ? chrome.runtime.getURL("")378 : "";379 if (!isMessageSenderAllowed(msg.type, sender, chrome.runtime.id, extensionOrigin)) {380 return { ok: false, error: "invalid_sender" };381 }382383 if (shouldRefreshAutoLock(msg.type, sender) && vault.isUnlocked()) {384 touchAutoLock();385 }386387 switch (msg.type) {388 case "CREATE_VAULT": {389 const { password } = msg.payload as { password: string };390 391 392 393 394 if (typeof password !== "string" || password.length === 0) {395 return { ok: false, error: "invalid_payload" };396 }397 await vault.createVault(password);398 await setupAutoLock();399 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });400 onVaultUnlocked();401 return { ok: true };402 }403 case "CREATE_VAULT_FROM_ROOT_KEY": {404 const { password, rootKeyHex } = (msg.payload ?? {}) as {405 password?: unknown;406 rootKeyHex?: unknown;407 };408 if (typeof password !== "string" || password.length === 0 || typeof rootKeyHex !== "string") {409 return { ok: false, error: "invalid_payload" };410 }411412 try {413 const { vaultId } = await vault.createVaultFromRootKey(password, rootKeyHex);414 await setupAutoLock();415 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });416 onVaultUnlocked();417 return { ok: true, vaultId };418 } catch (err) {419 return { ok: false, error: err instanceof Error ? err.message : String(err) };420 }421 }422 case "UNLOCK": {423 const { password } = msg.payload as { password: string };424 const ok = await vault.unlock(password);425 if (ok) {426 427 428 const kat = await runSelfTest();429 if (!kat.ok) {430 console.error("[Peach] Crypto self-test FAILED:", kat.error);431 await vault.lock();432 return { ok: false, error: "self_test_failed" };433 }434 await vault.updateSettings({ pinPreferred: false });435 await setupAutoLock();436 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });437 onVaultUnlocked();438 return { ok: true };439 }440 441 442 443 const failure = vault.consumeLastUnlockFailureReason();444 if (failure) {445 return {446 ok: false,447 error: "vault_corrupted",448 canRestoreFromHistory: true,449 } as MessageResponse;450 }451 return { ok: false };452 }453 case "UNLOCK_WITH_PIN": {454 const { pin } = (msg.payload ?? {}) as { pin?: unknown };455 if (typeof pin !== "string") return { ok: false, error: "invalid_payload" };456 const ok = await vault.unlockWithPin(pin);457 if (!ok) {458 const status = await vault.getPinUnlockStatus();459 if (status.configured && !status.available) {460 return {461 ok: false,462 error: status.attemptsRemaining === 0463 ? "pin_attempts_exceeded"464 : "pin_session_expired",465 };466 }467 return { ok: false };468 }469 const kat = await runSelfTest();470 if (!kat.ok) {471 await vault.lock();472 return { ok: false, error: "self_test_failed" };473 }474 await vault.updateSettings({ pinPreferred: true });475 await setupAutoLock();476 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });477 onVaultUnlocked();478 return { ok: true };479 }480 case "GET_PIN_STATUS":481 return { ok: true, data: await vault.getPinUnlockStatus() };482 case "VALIDATE_RECOVERY_WORDS": {483 const { words } = msg.payload as { words: string[] };484 const invalid = findInvalidWords(words);485 return { ok: true, data: { valid: invalid.length === 0, invalid } };486 }487 case "GET_RECOVERY_WORDS": {488 489 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };490 491 492 493 494 495 496 497 498 if (!(await isRecoveryConfirmPending())) {499 const { password } = (msg.payload ?? {}) as { password?: string };500 if (typeof password !== "string" || !password) {501 return { ok: false, error: "password_required" };502 }503 if (!(await vault.verifyMainPassword(password))) {504 return { ok: false, error: "wrong_password" };505 }506 }507 try {508 const words = await vault.getRecoveryWords();509 return { ok: true, data: words };510 } catch {511 return { ok: false, error: "recovery_not_setup" };512 }513 }514 case "GET_RECOVERY_CONFIRM_PENDING":515 return { ok: true, data: await isRecoveryConfirmPending() };516 case "SET_RECOVERY_CONFIRM_PENDING":517 await setRecoveryConfirmPending();518 return { ok: true };519 case "CLEAR_RECOVERY_CONFIRM_PENDING":520 await clearRecoveryConfirmPending();521 return { ok: true };522 case "RECOVER_WITH_WORDS": {523 524 525 const { words, newPassword } = msg.payload as {526 words: string[];527 newPassword: string;528 };529 const stored = await vault.loadSlotA();530 if (!stored) return { ok: false, error: "no_vault" };531 if (!stored.recovery_salt || !stored.recovery_wrapped_root_key) {532 return { ok: false, error: "recovery_not_setup" };533 }534 535 try {536 await vault.unwrapRootKeyForRecovery(537 stored.recovery_wrapped_root_key,538 stored.recovery_nonce ?? "",539 stored.recovery_tag ?? "",540 stored.recovery_salt,541 words542 );543 } catch {544 return { ok: false, error: "invalid_recovery_words" };545 }546 547 const newSalt = vault.generateSalt();548 const newKey = await vault.deriveKey(newPassword, newSalt);549 550 551 await vault.rewrapRootKeyForPassword(newKey, newSalt);552 553 await vault.changePasswordWithNewKey(newSalt, newKey);554 await setupAutoLock();555 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });556 return { ok: true };557 }558 case "LOCK":559 await vault.lock();560 return { ok: true };561 case "IS_UNLOCKED":562 return { ok: true, data: vault.isUnlocked() };563 case "GET_VAULT_STATUS":564 return { ok: true, unlocked: vault.isUnlocked() };565 case "HAS_VAULT":566 return { ok: true, data: await vault.hasVault() };567 case "GET_ENTRIES":568 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };569 return { ok: true, data: vault.getEntries() };570 case "EXPORT_ENTRIES": {571 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };572 const { password } = (msg.payload ?? {}) as { password?: unknown };573 if (typeof password !== "string" || !password) {574 return { ok: false, error: "password_required" };575 }576 if (!(await vault.verifyMainPassword(password))) {577 return { ok: false, error: "wrong_password" };578 }579 return { ok: true, data: vault.getEntries() };580 }581 case "ADD_ENTRY": {582 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };583 const payload = msg.payload;584 if (!payload || typeof payload !== "object" || Array.isArray(payload)) {585 return { ok: false, error: "invalid_payload" };586 }587 588 589 if (isPageSender(sender)) {590 const draft = payload as { url?: unknown; additionalUrls?: unknown };591 const domains = entryDomains(draft.url, draft.additionalUrls);592 if (domains.size > 0 && !senderMayBindEntryDomains(sender, domains)) {593 return { ok: false, error: "origin_binding_required" };594 }595 }596 const entry = await vault.addEntry(payload as Parameters<typeof vault.addEntry>[0]);597 return { ok: true, data: entry };598 }599 case "BATCH_ADD_ENTRIES": {600 const payload = msg.payload as { entries?: unknown } | null | undefined;601 if (!payload || typeof payload !== "object" || Array.isArray(payload)) {602 return { ok: false, error: "invalid_payload" };603 }604 if (!Array.isArray(payload.entries)) {605 return { ok: false, error: "invalid_payload" };606 }607 const added = await vault.batchAddEntries(payload.entries as Parameters<typeof vault.batchAddEntries>[0]);608 return { ok: true, data: added };609 }610 case "RESTORE_ENTRIES": {611 const payload = msg.payload as612 | { entries?: unknown; mode?: unknown; settings?: unknown }613 | null614 | undefined;615 if (!payload || typeof payload !== "object" || Array.isArray(payload)) {616 return { ok: false, error: "invalid_payload" };617 }618 if (!Array.isArray(payload.entries)) {619 return { ok: false, error: "invalid_payload" };620 }621 if (payload.mode !== "replace" && payload.mode !== "merge") {622 return { ok: false, error: "invalid_payload" };623 }624 const summary = await vault.restoreEntries(625 payload.entries as Parameters<typeof vault.restoreEntries>[0],626 payload.mode627 );628 if (payload.settings && typeof payload.settings === "object" && !Array.isArray(payload.settings)) {629 await vault.updateSettings(payload.settings as vault.VaultSettings);630 }631 return { ok: true, data: summary };632 }633 case "CLEAR_VAULT_ENTRIES": {634 await vault.clearVaultEntries("REPLACE_ALL");635 return { ok: true };636 }637 case "GET_VAULT_HISTORY": {638 const snapshots = await vault.getVaultHistory();639 return { ok: true, data: snapshots };640 }641 case "RESTORE_VAULT_SNAPSHOT": {642 const { snapshotId } = msg.payload as { snapshotId: string };643 const result = await vault.restoreVaultSnapshot(snapshotId);644 if (!result.ok) return { ok: false, error: result.error };645 broadcastToContentScripts({ type: "VAULT_LOCKED" });646 return { ok: true };647 }648 case "COUNT_DUPLICATES": {649 return { ok: true, data: vault.countDuplicates() };650 }651 case "DEDUPLICATE_ENTRIES": {652 const removed = await vault.deduplicateEntries();653 return { ok: true, data: removed };654 }655 case "UPDATE_ENTRY": {656 const payload = msg.payload as { id?: unknown; patch?: unknown } | null | undefined;657 if (!payload || typeof payload !== "object" || Array.isArray(payload)) {658 return { ok: false, error: "invalid_payload" };659 }660 const { id, patch } = payload;661 if (typeof id !== "string" || !id || !patch || typeof patch !== "object" || Array.isArray(patch)) {662 return { ok: false, error: "invalid_payload" };663 }664 const existingEntry = vault.getEntryById(id);665 if (!existingEntry) return { ok: false, error: "entry_not_found" };666 if (patchChangesEntryOrigin(patch) || isPageSender(sender)) {667 const before = entryDomains(existingEntry.url, existingEntry.additionalUrls);668 const after = patchedUrlShape(patch, existingEntry);669 if (!senderMayMutateEntryDomains(670 sender,671 before,672 entryDomains(after.url, after.additionalUrls),673 )) {674 return { ok: false, error: "origin_binding_required" };675 }676 }677 678 679 680 681 const incomingCanary = (patch as { canary?: unknown }).canary;682 if (incomingCanary && typeof incomingCanary === "object") {683 const existing = vault.getEntryById(id);684 if (!existing?.canary) {685 const gate = await requirePro("canary_entry");686 if (!gate.allowed) return { ok: false, error: "pro_required" };687 }688 }689 await vault.updateEntry(id, patch as Parameters<typeof vault.updateEntry>[1]);690 return { ok: true };691 }692 case "ADD_ADDITIONAL_URL": {693 const { id, url } = msg.payload as { id: string; url: string };694 const trimmed = typeof url === "string" ? url.trim() : "";695 if (!trimmed) return { ok: false, error: "url_required" };696 const existing = vault.getEntryById(id);697 if (!existing) return { ok: false, error: "entry_not_found" };698 const merged = [...(existing.additionalUrls ?? []), trimmed];699 if (!senderMayMutateEntryDomains(700 sender,701 entryDomains(existing.url, existing.additionalUrls),702 entryDomains(existing.url, merged),703 )) {704 return { ok: false, error: "origin_binding_required" };705 }706 if (existing.type !== "login") return { ok: false, error: "login_entries_only" };707 await vault.updateEntry(id, { additionalUrls: merged });708 return { ok: true };709 }710 case "REQUEST_REMEMBER_SITE": {711 712 713 714 715 const { id, url } = (msg.payload ?? {}) as { id?: unknown; url?: unknown };716 if (typeof id !== "string" || !id) return { ok: false, error: "invalid_payload" };717 const trimmed = typeof url === "string" ? url.trim() : "";718 if (!trimmed) return { ok: false, error: "url_required" };719 if (!senderMayBindEntryDomains(sender, entryDomains(trimmed, undefined))) {720 return { ok: false, error: "origin_binding_required" };721 }722 if (!vault.getEntryById(id)) return { ok: false, error: "entry_not_found" };723 if (chrome.storage.session) {724 await chrome.storage.session.set({ pendingRememberSite: { entryId: id, url: trimmed } });725 }726 try { await chrome.action.openPopup(); } catch { }727 return { ok: true };728 }729 case "DELETE_ENTRY": {730 const { id } = msg.payload as { id: string };731 await vault.deleteEntry(id);732 return { ok: true };733 }734 case "GET_DELETED_ENTRIES":735 return { ok: true, data: vault.getDeletedEntries() };736 case "RESTORE_ENTRY": {737 const { id } = msg.payload as { id: string };738 await vault.restoreEntry(id);739 return { ok: true };740 }741 case "PURGE_ENTRY": {742 const { id } = msg.payload as { id: string };743 await vault.purgeEntry(id);744 return { ok: true };745 }746 case "PURGE_EXPIRED_TRASH": {747 const { retentionMs } = msg.payload as { retentionMs: number };748 const purged = await vault.purgeExpiredTrash(retentionMs);749 return { ok: true, data: purged };750 }751 case "SAVE_NOTE": {752 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };753 const { id, title, body } = msg.payload as { id?: string; title: string; body: string };754 const now = Date.now();755 if (id) {756 const existing = vault.getEntryById(id);757 if (!existing) return { ok: false, error: "not_found" };758 await vault.updateEntry(id, { title: title || "Untitled", notes: body, modifiedAt: now });759 return { ok: true, data: vault.getEntryById(id) };760 } else {761 762 763 764 const entry = await vault.addEntry({765 type: "note",766 title: title || "Untitled",767 notes: body,768 });769 return { ok: true, data: entry };770 }771 }772 case "DELETE_NOTE": {773 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };774 const { id } = msg.payload as { id: string };775 await vault.deleteEntry(id);776 return { ok: true };777 }778 case "GET_NOTES": {779 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };780 const notes = vault.getEntries().filter(781 (e) => e.type === "note" && !e.deletedAt782 );783 return { ok: true, data: notes } as MessageResponse;784 }785 case "SEARCH_VAULT": {786 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };787 788 789 const { query } = (msg.payload ?? {}) as { query?: unknown };790 if (typeof query !== "string") {791 return { ok: false, error: "invalid_payload" };792 }793 const q = query.toLowerCase();794 const searchResults = vault.getEntries().filter(795 (e) =>796 !e.deletedAt &&797 (e.title.toLowerCase().includes(q) ||798 e.notes?.toLowerCase().includes(q) ||799 e.username?.toLowerCase().includes(q))800 );801 return { ok: true, data: searchResults } as MessageResponse;802 }803 case "AUTOFILL_QUERY": {804 if (!vault.isUnlocked()) {805 return { ok: false, error: "vault_locked" };806 }807 const { domain, kind, capability } = msg.payload as {808 domain: string;809 kind?: "login" | "card" | "totp";810 capability?: string;811 };812 const includeAll = consumeSummonCapability(capability, sender, "inventory");813 if (capability !== undefined && !includeAll) {814 return { ok: false, error: "entry_not_found" };815 }816 817 818 819 820 if (kind === "card") {821 if (!includeAll) return { ok: true, data: [] };822 const cards = vault.getEntries().filter((e) => e.type === "card");823 return {824 ok: true,825 data: cards.map((e) => ({826 id: e.id,827 title: e.cardNickname || e.title,828 cardholderName: e.cardholderName,829 last4: e.last4,830 expMonth: e.expMonth,831 expYear: e.expYear,832 network: e.network,833 })),834 };835 }836 const scopedDomain = isPageSender(sender)837 ? attestedSenderDomain(sender)838 : typeof domain === "string"839 ? extractDomain(domain)840 : null;841 if (!includeAll && !scopedDomain) {842 return isPageSender(sender)843 ? { ok: false, error: "origin_unverified" }844 : { ok: true, data: [] };845 }846 if (kind === "totp") {847 const matches = (848 includeAll849 ? vault.getEntries().filter((entry) => entry.type !== "note" && entry.type !== "card")850 : vault.queryDomain(scopedDomain!)851 ).filter((entry) => !isPasskeyOnly(entry) && Boolean(entry.totp));852 return {853 ok: true,854 data: matches.map((e) => ({855 id: e.id,856 title: e.title,857 username: e.username,858 email: e.email,859 url: e.url,860 hasTotp: true,861 })),862 };863 }864 865 866 867 868 869 const matches = (870 includeAll871 ? vault872 .getEntries()873 .filter((entry) => entry.type !== "note" && entry.type !== "card")874 : vault.queryDomain(scopedDomain!)875 ).filter((entry) => !isPasskeyOnly(entry));876 return {877 ok: true,878 data: matches.map((e) =>879 includeAll880 ? {881 id: e.id,882 title: e.title,883 884 885 886 887 888 username: e.username,889 email: e.email,890 url: e.url,891 hasPasskey: e.passkey !== undefined,892 }893 : {894 id: e.id,895 title: e.title,896 username: e.username,897 email: e.email,898 url: e.url,899 hasTotp: Boolean(e.totp),900 hasPasskey: e.passkey !== undefined,901 }902 ),903 };904 }905 case "GET_ENTRY_FOR_FILL": {906 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };907 const { id, capability } = msg.payload as {908 id: string;909 capability?: string;910 };911 const capabilityAuthorized = capability !== undefined && consumeSummonCapability(capability, sender, "fill");912 const senderDomain = isPageSender(sender) ? attestedSenderDomain(sender) : null;913 if (isPageSender(sender) && !senderDomain) {914 return { ok: false, error: "origin_unverified" };915 }916 const entry = vault.getEntryById(id);917 if (!entry) return { ok: false, error: "entry_not_found" };918919 920 921 922 923 924 925 926 if (senderDomain && !capabilityAuthorized) {927 if (!entryDomains(entry.url, entry.additionalUrls).has(senderDomain)) {928 929 return { ok: false, error: "entry_not_found" };930 }931 }932 933 934 935 if (isPasskeyOnly(entry)) return { ok: false, error: "passkey_not_fillable" };936 937 938 939 940 941 942 943 944 945 946 const {947 passkey: _strippedPasskey,948 totp: seed,949 totpAlgorithm,950 totpDigits,951 totpPeriod,952 passwordHistory: _ph,953 ...safeEntry954 } = entry;955 let totpCode: string | undefined;956 let totpRemaining: number | undefined;957 if (seed) {958 try {959 960 961 const generated = generateTOTP(seed, totpAlgorithm, totpDigits, totpPeriod);962 totpCode = generated.code;963 totpRemaining = generated.remaining;964 } catch {965 totpCode = undefined;966 }967 }968 if (isPageSender(sender) && vault.isUnlocked()) touchAutoLock();969 return { ok: true, data: { ...safeEntry, totpCode, totpRemaining } };970 }971 972 973 case "GET_ENTRY_FOR_EDIT": {974 if (isPageSender(sender)) return { ok: false, error: "invalid_sender" };975 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };976 const { id } = msg.payload as { id: string };977 const entry = vault.getEntryById(id);978 if (!entry) return { ok: false, error: "entry_not_found" };979980 const { passkey: _passkey, ...safeEntry } = entry;981 return { ok: true, data: safeEntry };982 }983 case "COPY": {984 985 986 987 const { text, label, alreadyWritten } = (msg.payload ?? {}) as {988 text?: unknown;989 label?: unknown;990 alreadyWritten?: unknown;991 };992 if (typeof text !== "string" || typeof label !== "string") {993 return { ok: false, error: "invalid_payload" };994 }995 await copyToClipboard(text, label, Boolean(alreadyWritten));996 return { ok: true, clearSeconds: getEffectiveClearSeconds() };997 }998 case "CHECK_ALL_BREACHES": {999 const allEntries = vault.getEntries();1000 const results: Record<string, number> = {};1001 for (const entry of allEntries) {1002 if (entry.type === "note") continue;1003 if (!entry.password) continue;1004 results[entry.id] = await vault.checkBreach(entry.password);1005 1006 await new Promise((r) => setTimeout(r, 200));1007 }1008 1009 for (const [id, count] of Object.entries(results)) {1010 const entry = vault.getEntryById(id);1011 if (entry && (entry.breach ?? 0) !== count) {1012 await vault.updateEntry(id, { breach: count > 0 ? count : undefined });1013 }1014 }1015 return { ok: true, data: results };1016 }1017 case "GET_SETTINGS": {1018 const settings = await vault.ensureSettingsLoaded();1019 if (!isPageSender(sender)) return { ok: true, data: settings };10201021 const safeSettings: Pick<vault.VaultSettings, "accentColor" | "theme"> & {1022 manualSummonDomains?: NonNullable<vault.VaultSettings["manualSummonDomains"]>;1023 } = {1024 accentColor: settings.accentColor,1025 theme: settings.theme,1026 };1027 const domain = attestedSenderDomain(sender);1028 const ownSummon = domain ? settings.manualSummonDomains?.[domain] : undefined;1029 if (domain && ownSummon) safeSettings.manualSummonDomains = { [domain]: ownSummon };1030 return { ok: true, data: safeSettings };1031 }1032 case "UPDATE_SETTINGS": {1033 const patch = msg.payload;1034 if (!patch || typeof patch !== "object" || Array.isArray(patch)) {1035 return { ok: false, error: "invalid_payload" };1036 }1037 await vault.updateSettings(patch as vault.VaultSettings);1038 if (typeof (patch as Record<string, unknown>).autoLockMinutes === "number") {1039 await setupAutoLock();1040 if (vault.isUnlocked()) touchAutoLock();1041 }1042 return { ok: true };1043 }1044 case "SETUP_PIN": {1045 const { currentPassword, pin } = (msg.payload ?? {}) as {1046 currentPassword?: unknown;1047 pin?: unknown;1048 };1049 if (typeof currentPassword !== "string" || typeof pin !== "string") {1050 return { ok: false, error: "invalid_payload" };1051 }1052 return (await vault.setupPinUnlock(currentPassword, pin))1053 ? { ok: true }1054 : { ok: false, error: "incorrect_current_password" };1055 }1056 case "DISABLE_PIN": {1057 const { currentPassword } = (msg.payload ?? {}) as { currentPassword?: unknown };1058 if (typeof currentPassword !== "string") return { ok: false, error: "invalid_payload" };1059 return (await vault.disablePinUnlock(currentPassword))1060 ? { ok: true }1061 : { ok: false, error: "incorrect_current_password" };1062 }1063 case "GET_PENDING_PASSKEY":1064 return { ok: true, data: await getPendingPasskeyRequest() };1065 case "APPROVE_PASSKEY": {1066 const { requestId } = msg.payload as { requestId: number };1067 await resolvePendingPasskeyRequest(requestId, true);1068 return { ok: true };1069 }1070 case "DENY_PASSKEY": {1071 const { requestId } = msg.payload as { requestId: number };1072 await resolvePendingPasskeyRequest(requestId, false);1073 return { ok: true };1074 }1075 case "GET_PASSKEY_PROVIDER_STATE":1076 return { ok: true, data: await getPasskeyProviderState() };1077 case "SET_PASSKEY_PROVIDER_ENABLED": {1078 const { enabled } = msg.payload as { enabled: boolean };1079 1080 1081 if (enabled) {1082 const gate = await requirePro("passkeys");1083 if (!gate.allowed) return { ok: false, error: "pro_required" };1084 }1085 return { ok: true, data: await setPasskeyProviderEnabled(enabled) };1086 }1087 1088 1089 1090 1091 1092 1093 case "FIRE_CANARY_PING": {1094 1095 1096 1097 1098 const { webhookUrl, entryTitle, trigger } = msg.payload as {1099 webhookUrl: string;1100 entryTitle: string;1101 trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";1102 };1103 if (!isAllowedCanaryWebhookUrl(webhookUrl)) {1104 return { ok: false, error: "invalid_webhook_url" };1105 }1106 1107 1108 1109 1110 1111 1112 1113 if (trigger !== "test") {1114 const rlKey = `canary-last-fetch:${webhookUrl}:${entryTitle}`;1115 const stored = await chrome.storage.local.get(rlKey);1116 const last = (stored as Record<string, unknown>)[rlKey];1117 if (typeof last === "number" && Date.now() - last < CANARY_MIN_INTERVAL_MS) {1118 return { ok: true, data: { rateLimited: true } };1119 }1120 await chrome.storage.local.set({ [rlKey]: Date.now() });1121 }1122 const body = JSON.stringify({1123 event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",1124 entry: entryTitle,1125 trigger,1126 timestamp: new Date().toISOString(),1127 source: "peach-extension",1128 });1129 if (trigger === "test") {1130 1131 1132 1133 1134 1135 try {1136 const res = await fetch(webhookUrl, {1137 method: "POST",1138 headers: { "Content-Type": "application/json" },1139 body,1140 });1141 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };1142 } catch (err) {1143 return { ok: false, error: err instanceof Error ? err.message : "network_error" };1144 }1145 }1146 1147 fetch(webhookUrl, {1148 method: "POST",1149 headers: { "Content-Type": "application/json" },1150 body,1151 }).catch(() => {});1152 return { ok: true };1153 }1154 case "ACTIVATE_LICENSE": {1155 const { key } = msg.payload as { key: string };1156 const licenseInfo = await activateLicense(key);1157 return { ok: true, data: licenseInfo };1158 }1159 case "GET_LICENSE": {1160 const licenseInfo = await getLicenseInfo();1161 return { ok: true, data: licenseInfo };1162 }1163 case "GET_LICENSE_KEY": {1164 const key = await getLicenseKey();1165 return { ok: true, data: key };1166 }1167 case "DEACTIVATE_LICENSE": {1168 await deactivateLicense();1169 return { ok: true };1170 }1171 case "SHOULD_PLAY_CELEBRATION": {1172 const should = await shouldPlayCelebration();1173 return { ok: true, data: should };1174 }1175 case "MARK_CELEBRATION_PLAYED": {1176 await markCelebrationPlayed();1177 return { ok: true };1178 }1179 case "GET_CODEX_VAULT_DATA": {1180 1181 1182 1183 1184 const gate = await requirePro("codex");1185 if (!gate.allowed) return { ok: false, error: "pro_required" };1186 const { password } = (msg.payload ?? {}) as { password?: string };1187 return await handleGetCodexVaultData(password);1188 }1189 case "ESTIMATE_CODEX_PAGES": {1190 const gate = await requirePro("codex");1191 if (!gate.allowed) return { ok: false, error: "pro_required" };1192 return await handleEstimateCodexPages();1193 }1194 case "RESTORE_FROM_CODEX": {1195 1196 1197 1198 const payload = msg.payload as CodexRestorePayload;1199 return await handleRestoreFromCodex(payload);1200 }1201 case "OPEN_PRO_GATE": {1202 1203 const { feature } = (msg.payload ?? {}) as { feature?: string };1204 if (chrome.storage.session) {1205 await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });1206 }1207 try { await chrome.action.openPopup(); } catch { }1208 return { ok: true };1209 }1210 case "OPEN_POPUP": {1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 const { entryId } = (msg.payload ?? {}) as { entryId?: unknown };1221 if (entryId !== undefined && (typeof entryId !== "string" || entryId.length === 0 || entryId.length > 64)) {1222 return { ok: false, error: "invalid_payload" };1223 }1224 if (chrome.storage.session) {1225 if (typeof entryId === "string") {1226 await chrome.storage.session.set({ peach_open_entry: entryId });1227 } else {1228 await chrome.storage.session.set({ peach_inline_unlock: true });1229 }1230 }1231 try { await chrome.action.openPopup(); } catch { }1232 return { ok: true };1233 }1234 case "OPEN_DETACHED_POPUP": {1235 1236 1237 1238 1239 1240 1241 const { route } = (msg.payload ?? {}) as { route?: string };1242 const search = route === "peachscript"1243 ? "?restore=peachscript"1244 : route === "csv"1245 ? "?restore=csv"1246 : route === "codex-pdf"1247 ? "?codex=pdf"1248 : null;1249 if (!search) return { ok: false, error: "bad_route" };1250 try {1251 const popupHtml =1252 chrome.runtime.getManifest().action?.default_popup ?? "src/popup/index.html";1253 await chrome.windows.create({1254 url: chrome.runtime.getURL(`${popupHtml}${search}`),1255 type: "popup",1256 width: 320,1257 height: 550,1258 });1259 return { ok: true, data: { detached: true } };1260 } catch {1261 return { ok: false };1262 }1263 }1264 case "GET_IDENTITIES": {1265 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1266 return { ok: true, data: vault.getIdentities() };1267 }1268 case "TOUCH_AUTO_LOCK": {1269 1270 1271 return { ok: true };1272 }1273 case "GET_ADVANCED_HEALTH_REPORT": {1274 const gate = await requirePro("advanced_health");1275 if (!gate.allowed) return { ok: false, error: "pro_required" };1276 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1277 const report = await buildAdvancedHealthReport(vault.getEntries());1278 return { ok: true, data: report };1279 }1280 case "IS_DURESS_ENABLED": {1281 const enabled = await vault.isDuressEnabled();1282 return { ok: true, data: enabled };1283 }1284 case "SETUP_DURESS": {1285 1286 1287 1288 1289 const gate = await requirePro("duress_vault");1290 if (!gate.allowed) return { ok: false, error: "pro_required" };1291 1292 1293 1294 if (!vault.isUnlocked()) return { ok: false, error: "session_expired" };1295 const { duressPassword } = msg.payload as { duressPassword: string };1296 await vault.setupDuress(duressPassword);1297 return { ok: true };1298 }1299 case "DISABLE_DURESS": {1300 await vault.disableDuress();1301 return { ok: true };1302 }1303 case "VERIFY_MAIN_PASSWORD": {1304 const { password } = msg.payload as { password: string };1305 const ok = await vault.verifyMainPassword(password);1306 return { ok: true, data: ok };1307 }1308 case "CHANGE_PASSWORD": {1309 const { currentPassword, newPassword } = msg.payload as {1310 currentPassword: string;1311 newPassword: string;1312 };1313 const changed = await vault.changePassword(currentPassword, newPassword);1314 return changed1315 ? { ok: true }1316 : { ok: false, error: "incorrect_current_password" };1317 }1318 case "GET_SITE_PASSWORD_POLICY": {1319 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });1320 if (!tab?.id) return { ok: true, data: null };1321 try {1322 const response = await chrome.tabs.sendMessage(tab.id, {1323 type: "GET_SITE_PASSWORD_POLICY",1324 });1325 return (response as MessageResponse) ?? { ok: true, data: null };1326 } catch {1327 return { ok: true, data: null };1328 }1329 }1330 case "RECORD_MANUAL_SUMMON": {1331 const { domain } = msg.payload as { domain: string };1332 const settings = vault.getSettings();1333 const existing = settings.manualSummonDomains?.[domain];1334 const now = Date.now();1335 await vault.updateSettings({1336 manualSummonDomains: {1337 ...settings.manualSummonDomains,1338 [domain]: {1339 firstSummonedAt: existing?.firstSummonedAt ?? now,1340 summonCount: (existing?.summonCount ?? 0) + 1,1341 lastSummonedAt: now,1342 },1343 },1344 });1345 return { ok: true };1346 }1347 case "DECLINE_CARD_SAVE": {1348 1349 1350 1351 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1352 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1353 await vault.declineCardSaveOffer(last4, expMonth, expYear);1354 return { ok: true };1355 }1356 case "IS_CARD_DECLINED": {1357 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1358 const declined = await vault.isCardDeclined(last4, expMonth, expYear);1359 return { ok: true, data: declined };1360 }1361 case "INITIATE_FIREFOX_PASSKEY_CREATE": {1362 const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {1363 rpId?: string;1364 rpName?: string;1365 userName?: string;1366 userDisplayName?: string;1367 };1368 if (!rpId || !userName) return { ok: false, error: "rpid_and_username_required" };1369 return await initiateFirefoxPasskeyCreate({1370 rpId,1371 rpName: rpName ?? rpId,1372 userName,1373 userDisplayName,1374 }, attestedSenderOrigin(sender));1375 }13761377 case "COMPLETE_FIREFOX_PASSKEY_CREATE": {1378 const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;1379 if (!payload) return { ok: false, error: "missing_payload" };1380 return await completeFirefoxPasskeyCreate(payload);1381 }13821383 case "PASSKEY_OFFER_DECLINE": {1384 const { domain } = (msg.payload ?? {}) as { domain?: string };1385 const origin = attestedSenderOrigin(sender);1386 if (!domain || !origin || !isValidRpId(domain, origin)) return { ok: false, error: "invalid_domain" };1387 await recordPasskeyOfferDecline(domain);1388 return { ok: true };1389 }13901391 case "GET_PASSKEY_OFFER_DECLINED": {1392 const { domain } = (msg.payload ?? {}) as { domain?: string };1393 const origin = attestedSenderOrigin(sender);1394 if (!domain || !origin || !isValidRpId(domain, origin)) return { ok: false, error: "invalid_domain" };1395 return { ok: true, data: await isPasskeyOfferDeclined(domain) };1396 }13971398 case "PASSKEY_HAS_CREDENTIALS": {1399 const payload = msg.payload as {1400 rpId?: string;1401 allowCredentials?: Array<{ id: string; type?: string }>;1402 } | undefined;1403 if (!payload?.rpId) return { ok: false, error: "rpid_required" };1404 return await handlePasskeyHasCredentials({1405 rpId: payload.rpId,1406 allowCredentials: payload.allowCredentials ?? [],1407 }, attestedSenderOrigin(sender));1408 }14091410 case "PASSKEY_CREATE": {1411 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;1412 if (!payload?.rpId || !payload?.userName) {1413 return { ok: false, error: "rpid_and_username_required" };1414 }1415 return await handleFirefoxPasskeyCreate(payload, attestedSenderOrigin(sender));1416 }14171418 case "PASSKEY_GET": {1419 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;1420 if (!payload?.rpId) return { ok: false, error: "rpid_required" };1421 return await handleFirefoxPasskeyGet(payload, attestedSenderOrigin(sender));1422 }14231424 case "PASSKEY_AUTH_RESPONSE": {1425 const { requestId, approved } = (msg.payload ?? {}) as {1426 requestId?: string | number;1427 approved?: boolean;1428 };1429 if (requestId === undefined || requestId === null || typeof approved !== 'boolean') {1430 return { ok: false, error: 'requestId and approved required' };1431 }1432 const pending = await getPendingPasskeyRequest();1433 const senderOrigin = attestedSenderOrigin(sender);1434 if (1435 (pending && pending.requestId !== Number(requestId)) ||1436 (pending?.tabId !== undefined && sender.tab?.id !== pending.tabId) ||1437 (pending?.origin !== undefined && senderOrigin !== pending.origin)1438 ) {1439 return { ok: false, error: "invalid_passkey_approval_sender" };1440 }1441 await resolvePendingPasskeyRequest(Number(requestId), approved);1442 return { ok: true };1443 }14441445 case "GET_PASSKEY_FOR_DOMAIN": {1446 const { domain } = (msg.payload ?? {}) as { domain?: string };1447 if (!domain) return { ok: false, error: "domain_required" };1448 1449 1450 1451 const origin = attestedSenderOrigin(sender);1452 if (!origin || !isValidRpId(domain, origin)) return { ok: true, data: null };1453 if (!vault.isUnlocked()) return { ok: true, data: null };1454 const match = vault1455 .getEntries()1456 .find((entry) => entry.passkey?.rpId === domain);1457 if (!match || !match.passkey) return { ok: true, data: null };1458 1459 1460 1461 1462 1463 let bannerShown = false;1464 if (chrome.storage.session) {1465 const flag = await chrome.storage.session.get(`peach_state11_shown:${domain}`);1466 bannerShown = Boolean((flag as Record<string, unknown>)[`peach_state11_shown:${domain}`]);1467 }1468 return {1469 ok: true,1470 data: {1471 rpId: match.passkey.rpId,1472 userName: match.passkey.userName,1473 browser: match.passkey.browser,1474 bannerShown,1475 },1476 };1477 }14781479 case "MARK_PASSKEY_BANNER_SHOWN": {1480 const { domain } = (msg.payload ?? {}) as { domain?: string };1481 if (typeof domain !== "string" || !domain) return { ok: false, error: "domain_required" };1482 1483 1484 const origin = attestedSenderOrigin(sender);1485 if (!origin || !isValidRpId(domain, origin)) return { ok: true };1486 if (chrome.storage.session) {1487 await chrome.storage.session.set({ [`peach_state11_shown:${domain}`]: Date.now() });1488 }1489 return { ok: true };1490 }14911492 case "DESTROY_VAULT": {1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 const preserved = await chrome.storage.local.get([1504 "peach_license",1505 "peach_pro_celebrated",1506 ]);1507 await vault.lock();1508 await storageClear();1509 await vault.clearVaultHistory();1510 const toRestore: Record<string, unknown> = {};1511 if (preserved.peach_license !== undefined) toRestore.peach_license = preserved.peach_license;1512 if (preserved.peach_pro_celebrated !== undefined) toRestore.peach_pro_celebrated = preserved.peach_pro_celebrated;1513 if (Object.keys(toRestore).length > 0) {1514 await chrome.storage.local.set(toRestore);1515 }1516 if (chrome.storage.session) {1517 await chrome.storage.session.clear();1518 }1519 await clearRecoveryConfirmPending();1520 return { ok: true };1521 }15221523 case "GET_IDENTITY_SUGGESTIONS": {1524 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1525 const entries = vault.getEntries();1526 const suggestions = buildIdentitySuggestions(entries);1527 return { ok: true, data: { suggestions } };1528 }1529 case "ML_INIT":1530 return { ok: true };15311532 case "ML_PREDICT_BATCH": {1533 1534 const { featuresArray } = (msg.payload ?? {}) as { featuresArray?: unknown };1535 if (!Array.isArray(featuresArray)) {1536 return { ok: false, error: "invalid_payload" };1537 }1538 return { ok: true, data: mlPredictBatch(featuresArray as MLFieldFeatures[]) };1539 }15401541 1542 1543 1544 1545 case "GET_LOCAL_IDENTITY": {1546 1547 1548 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1549 1550 1551 const masterKeyHex = vault.getRootKeyHex();1552 if (!masterKeyHex) return { ok: false, error: "vault_locked" };1553 const masterKey = unhexBytes(masterKeyHex);1554 try {1555 const identity = await buildLocalIdentity(masterKey);1556 1557 1558 if (await isProUser()) {1559 const license = await getLicenseKey();1560 if (license) (identity as { license?: string }).license = license;1561 }1562 return { ok: true, data: identity };1563 } finally {1564 masterKey.fill(0);1565 }1566 }15671568 case "LIST_PAIRED_DEVICES": {1569 const devices = await listPairedDevicesV3();1570 return { ok: true, data: { devices } };1571 }15721573 case "RENAME_PAIRED_DEVICE": {1574 const { deviceId, name } = (msg.payload ?? {}) as { deviceId?: string; name?: string };1575 if (typeof deviceId !== "string" || !deviceId || typeof name !== "string") {1576 return { ok: false, error: "invalid_payload" };1577 }1578 await renamePairedDeviceV3(deviceId, name);1579 return { ok: true };1580 }15811582 case "UNPAIR_DEVICE": {1583 const { deviceId } = (msg.payload ?? {}) as { deviceId?: string };1584 if (typeof deviceId !== "string" || !deviceId) {1585 return { ok: false, error: "invalid_payload" };1586 }1587 1588 1589 1590 await revokeDeviceByIdV3(deviceId, await getOrCreateDeviceId());1591 1592 1593 1594 const unpairRootHex = vault.getRootKeyHex();1595 if (unpairRootHex) {1596 const unpairRoot = unhexBytes(unpairRootHex);1597 try {1598 await dropPairSecret(deviceId, unpairRoot);1599 } finally {1600 unpairRoot.fill(0);1601 }1602 } else {1603 await dropPairSecret(deviceId, null);1604 }1605 1606 1607 void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);1608 return { ok: true };1609 }16101611 case "RENAME_LOCAL_DEVICE": {1612 const { name } = (msg.payload ?? {}) as { name?: string };1613 if (typeof name !== "string") return { ok: false, error: "invalid_payload" };1614 await setDeviceNameV3(name);1615 return { ok: true };1616 }16171618 1619 1620 1621 1622 case "START_ADOPTION_SOURCE": {1623 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1624 if (isRelayAdoptionSourceInFlight()) {1625 return { ok: false, error: "adoption_source_in_progress" };1626 }1627 try {1628 return { ok: true, data: await startRelayAdoptionSource() };1629 } catch (error) {1630 return {1631 ok: false,1632 error: error instanceof Error ? error.message : "adoption_source_failed",1633 };1634 }1635 }16361637 1638 1639 1640 1641 case "START_ADOPTION_RECEIVER": {1642 if (isRelayAdoptionSourceInFlight()) {1643 return { ok: false, error: "adoption_receiver_in_progress" };1644 }1645 const password = (msg.payload as { password?: unknown } | undefined)?.password;1646 if (typeof password !== "string" || password.length === 0) {1647 return { ok: false, error: "invalid_payload" };1648 }1649 try {1650 return { ok: true, data: await startRelayAdoptionReceiver(password) };1651 } catch (error) {1652 return {1653 ok: false,1654 error: error instanceof Error ? error.message : "adoption_receiver_failed",1655 };1656 }1657 }16581659 1660 1661 1662 1663 1664 1665 16661667 1668 1669 1670 case "SOURCE_SAS_DECISION": {1671 const payload = msg.payload as { id?: unknown; ok?: unknown } | undefined;1672 if (typeof payload?.id !== "string" || typeof payload?.ok !== "boolean") {1673 return { ok: false, error: "invalid_payload" };1674 }1675 return { ok: resolveSourceSasConfirmation(payload.id, payload.ok) };1676 }16771678 case "CANCEL_ADOPTION_SOURCE": {1679 cancelRelayAdoptionSource();1680 return { ok: true };1681 }16821683 case "TRIGGER_SYNC": {1684 const payload = (msg.payload ?? {}) as {1685 reason?: "vault_change" | "foreground" | "manual";1686 force?: boolean;1687 };1688 const reason = payload.reason ?? "foreground";16891690 void triggerSyncIfReachable({ reason, force: payload.force === true });1691 return { ok: true };1692 }16931694 case "GET_SYNC_STATE": {1695 return { ok: true, data: getSyncState() };1696 }16971698 case "GET_SYNC_SETTINGS": {1699 return { ok: true, data: await getSyncSettings() };1700 }17011702 case "SET_SYNC_SETTINGS": {1703 const payload = (msg.payload ?? {}) as {1704 mode?: unknown;1705 relayUrl?: unknown;1706 };1707 try {1708 1709 1710 if (payload.relayUrl !== undefined) {1711 if (typeof payload.relayUrl !== "string") {1712 return { ok: false, error: "invalid_relay_url" };1713 }1714 return { ok: true, data: await setRelayUrl(payload.relayUrl) };1715 }1716 if (payload.mode !== undefined) {1717 if (payload.mode !== "public" && payload.mode !== "private" && payload.mode !== "off") {1718 return { ok: false, error: "invalid_sync_mode" };1719 }1720 return { ok: true, data: await setSyncMode(payload.mode as SyncMode) };1721 }1722 return { ok: true, data: await getSyncSettings() };1723 } catch (error) {1724 return {1725 ok: false,1726 error: error instanceof Error ? error.message : "invalid_relay_url",1727 };1728 }1729 }17301731 case "GET_SYNC_ALERTS": {1732 const { consume } = (msg.payload ?? {}) as { consume?: boolean };1733 const alerts = await getPendingSyncAlerts();1734 1735 1736 1737 if (consume) await clearPendingSyncAlerts();1738 return { ok: true, data: alerts };1739 }17401741 case "GET_PENDING_VOUCHED_DEVICES": {1742 return { ok: true, data: await listPendingVouchedDevices() };1743 }1744 case "CONFIRM_VOUCHED_DEVICE": {1745 const { deviceId } = msg.payload as { deviceId: string };1746 const pending = await getPendingVouchedDevice(deviceId);1747 if (!pending) return { ok: false, error: "not_pending" };1748 1749 1750 1751 if (await isDeviceRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {1752 await removePendingVouchedDevice(deviceId);1753 return { ok: false, error: "device_revoked" };1754 }1755 await upsertPairedDeviceV3({1756 deviceId: pending.deviceId,1757 deviceName: pending.deviceName,1758 deviceType:1759 pending.deviceType as import("../shared/sync-types").PairedDevice["deviceType"],1760 longTermPublicKeyHex: pending.longTermPublicKeyHex,1761 signingPublicKeyHex: pending.signingPublicKeyHex,1762 wsPort: pending.wsPort,1763 pairedAt: pending.pairedAt,1764 trustSource: "vouched",1765 });1766 await removePendingVouchedDevice(deviceId);1767 1768 1769 void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);1770 return { ok: true };1771 }1772 case "REJECT_VOUCHED_DEVICE": {1773 const { deviceId } = msg.payload as { deviceId: string };1774 await removePendingVouchedDevice(deviceId);1775 return { ok: true };1776 }1777 case "ROTATE_SYNC_IDENTITY": {1778 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1779 const result = await vault.rotateSyncIdentity();1780 return result.ok ? { ok: true } : { ok: false, error: result.error };1781 }17821783 default:1784 return { ok: false, error: "unknown_message_type" };1785 }1786 } catch (err) {1787 return {1788 ok: false,1789 error: err instanceof Error ? err.message : "operation_failed",1790 };1791 }1792}17931794179517961797function buildIdentitySuggestions(1798 entries: VaultEntry[],1799): Array<{ email: string; username: string; count: number }> {1800 const usernameCounts = new Map<string, number>();1801 const usernameEmail = new Map<string, string>();1802 for (const entry of entries) {1803 const username = entry.username ?? "";1804 const email = (entry as VaultEntry & { email?: string }).email ?? "";1805 if (!username && !email) continue;1806 const key = username || email;1807 usernameCounts.set(key, (usernameCounts.get(key) ?? 0) + 1);1808 if (!usernameEmail.has(key)) usernameEmail.set(key, email);1809 }1810 return Array.from(usernameCounts.entries())1811 .sort((a, b) => b[1] - a[1])1812 .map(([username, count]) => ({1813 email: usernameEmail.get(username) ?? "",1814 username,1815 count,1816 }));1817}181818191820function broadcastToContentScripts(message: { type: string }): void {1821 if (!chrome.tabs?.query) return;1822 chrome.tabs.query({}, (tabs) => {1823 for (const tab of tabs) {1824 if (tab.id !== undefined) {1825 chrome.tabs.sendMessage(tab.id, message).catch(() => {1826 1827 });1828 }1829 }1830 });1831}18321833function shouldRefreshAutoLock(1834 type: MessageType,1835 sender: Partial<chrome.runtime.MessageSender>,1836): boolean {1837 if (!isExtensionPageSender(sender)) return false;1838 return (1839 type !== "HAS_VAULT" &&1840 type !== "IS_UNLOCKED" &&1841 type !== "GET_VAULT_STATUS" &&1842 type !== "LOCK" &&1843 type !== "UPDATE_SETTINGS" &&1844 type !== "GET_PENDING_PASSKEY" &&1845 type !== "GET_PASSKEY_PROVIDER_STATE" &&1846 type !== "DECLINE_CARD_SAVE" &&1847 type !== "IS_CARD_DECLINED" &&1848 type !== "AUTOFILL_QUERY" &&1849 type !== "ML_PREDICT_BATCH"1850 );1851}18521853function isAllowedCanaryWebhookUrl(value: string): boolean {1854 try {1855 const url = new URL(value);1856 if (url.protocol !== "https:") return false;18571858 const hostname = url.hostname.toLowerCase();1859 if (1860 hostname === "localhost" ||1861 hostname.endsWith(".localhost") ||1862 isPrivateIpv4(hostname) ||1863 isPrivateIpv6(hostname)1864 ) {1865 return false;1866 }18671868 return true;1869 } catch {1870 return false;1871 }1872}18731874function isPrivateIpv4(hostname: string): boolean {1875 if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {1876 return true;1877 }18781879 const octets = hostname.split(".").map((part) => Number(part));1880 if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {1881 return false;1882 }18831884 return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;1885}18861887function isPrivateIpv6(hostname: string): boolean {1888 const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();1889 1890 1891 1892 1893 1894 1895 const mapped = /^::ffff:(.+)$/.exec(normalized);1896 if (mapped) {1897 const tail = mapped[1] ?? "";1898 if (tail.includes(".")) return isPrivateIpv4(tail);1899 const parts = tail.split(":");1900 const [hi, lo] = parts;1901 if (1902 parts.length !== 2 ||1903 !hi ||1904 !lo ||1905 !/^[0-9a-f]{1,4}$/.test(hi) ||1906 !/^[0-9a-f]{1,4}$/.test(lo)1907 ) {1908 return true;1909 }1910 const octets = [1911 (Number.parseInt(hi, 16) >>> 8) & 0xff,1912 Number.parseInt(hi, 16) & 0xff,1913 (Number.parseInt(lo, 16) >>> 8) & 0xff,1914 Number.parseInt(lo, 16) & 0xff,1915 ];1916 return isPrivateIpv4(octets.join("."));1917 }1918 return (1919 normalized === "::1" ||1920 normalized.startsWith("fc") ||1921 normalized.startsWith("fd") ||1922 normalized.startsWith("fe80:")1923 );1924}