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