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?: unknown };1333 const origin = attestedSenderOrigin(sender);1334 let senderDomain: string | null = null;1335 if (origin) {1336 try {1337 const hostname = new URL(origin).hostname;1338 senderDomain = extractDomain(hostname) ?? hostname;1339 } catch {1340 senderDomain = null;1341 }1342 }1343 if (typeof domain !== "string" || domain.length > 253 || domain !== senderDomain) {1344 return { ok: false, error: "invalid_domain" };1345 }1346 const settings = vault.getSettings();1347 const existing = settings.manualSummonDomains?.[domain];1348 const now = Date.now();1349 await vault.updateSettings({1350 manualSummonDomains: {1351 ...settings.manualSummonDomains,1352 [domain]: {1353 firstSummonedAt: existing?.firstSummonedAt ?? now,1354 summonCount: (existing?.summonCount ?? 0) + 1,1355 lastSummonedAt: now,1356 },1357 },1358 });1359 return { ok: true };1360 }1361 case "DECLINE_CARD_SAVE": {1362 1363 1364 1365 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1366 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1367 await vault.declineCardSaveOffer(last4, expMonth, expYear);1368 return { ok: true };1369 }1370 case "IS_CARD_DECLINED": {1371 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1372 const declined = await vault.isCardDeclined(last4, expMonth, expYear);1373 return { ok: true, data: declined };1374 }1375 case "INITIATE_FIREFOX_PASSKEY_CREATE": {1376 const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {1377 rpId?: string;1378 rpName?: string;1379 userName?: string;1380 userDisplayName?: string;1381 };1382 if (!rpId || !userName) return { ok: false, error: "rpid_and_username_required" };1383 return await initiateFirefoxPasskeyCreate({1384 rpId,1385 rpName: rpName ?? rpId,1386 userName,1387 userDisplayName,1388 }, attestedSenderOrigin(sender));1389 }13901391 case "COMPLETE_FIREFOX_PASSKEY_CREATE": {1392 const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;1393 if (!payload) return { ok: false, error: "missing_payload" };1394 return await completeFirefoxPasskeyCreate(payload);1395 }13961397 case "PASSKEY_OFFER_DECLINE": {1398 const { domain } = (msg.payload ?? {}) as { domain?: string };1399 const origin = attestedSenderOrigin(sender);1400 if (!domain || !origin || !isValidRpId(domain, origin)) return { ok: false, error: "invalid_domain" };1401 await recordPasskeyOfferDecline(domain);1402 return { ok: true };1403 }14041405 case "GET_PASSKEY_OFFER_DECLINED": {1406 const { domain } = (msg.payload ?? {}) as { domain?: string };1407 const origin = attestedSenderOrigin(sender);1408 if (!domain || !origin || !isValidRpId(domain, origin)) return { ok: false, error: "invalid_domain" };1409 return { ok: true, data: await isPasskeyOfferDeclined(domain) };1410 }14111412 case "PASSKEY_HAS_CREDENTIALS": {1413 const payload = msg.payload as {1414 rpId?: string;1415 allowCredentials?: Array<{ id: string; type?: string }>;1416 } | undefined;1417 if (!payload?.rpId) return { ok: false, error: "rpid_required" };1418 return await handlePasskeyHasCredentials({1419 rpId: payload.rpId,1420 allowCredentials: payload.allowCredentials ?? [],1421 }, attestedSenderOrigin(sender));1422 }14231424 case "PASSKEY_CREATE": {1425 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;1426 if (!payload?.rpId || !payload?.userName) {1427 return { ok: false, error: "rpid_and_username_required" };1428 }1429 return await handleFirefoxPasskeyCreate(payload, attestedSenderOrigin(sender));1430 }14311432 case "PASSKEY_GET": {1433 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;1434 if (!payload?.rpId) return { ok: false, error: "rpid_required" };1435 return await handleFirefoxPasskeyGet(payload, attestedSenderOrigin(sender));1436 }14371438 case "PASSKEY_AUTH_RESPONSE": {1439 const { requestId, approved } = (msg.payload ?? {}) as {1440 requestId?: string | number;1441 approved?: boolean;1442 };1443 if (requestId === undefined || requestId === null || typeof approved !== 'boolean') {1444 return { ok: false, error: 'requestId and approved required' };1445 }1446 const pending = await getPendingPasskeyRequest();1447 const senderOrigin = attestedSenderOrigin(sender);1448 if (1449 (pending && pending.requestId !== Number(requestId)) ||1450 (pending?.tabId !== undefined && sender.tab?.id !== pending.tabId) ||1451 (pending?.origin !== undefined && senderOrigin !== pending.origin)1452 ) {1453 return { ok: false, error: "invalid_passkey_approval_sender" };1454 }1455 await resolvePendingPasskeyRequest(Number(requestId), approved);1456 return { ok: true };1457 }14581459 case "GET_PASSKEY_FOR_DOMAIN": {1460 const { domain } = (msg.payload ?? {}) as { domain?: string };1461 if (!domain) return { ok: false, error: "domain_required" };1462 1463 1464 1465 const origin = attestedSenderOrigin(sender);1466 if (!origin || !isValidRpId(domain, origin)) return { ok: true, data: null };1467 if (!vault.isUnlocked()) return { ok: true, data: null };1468 const match = vault1469 .getEntries()1470 .find((entry) => entry.passkey?.rpId === domain);1471 if (!match || !match.passkey) return { ok: true, data: null };1472 1473 1474 1475 1476 1477 let bannerShown = false;1478 if (chrome.storage.session) {1479 const flag = await chrome.storage.session.get(`peach_state11_shown:${domain}`);1480 bannerShown = Boolean((flag as Record<string, unknown>)[`peach_state11_shown:${domain}`]);1481 }1482 return {1483 ok: true,1484 data: {1485 rpId: match.passkey.rpId,1486 userName: match.passkey.userName,1487 browser: match.passkey.browser,1488 bannerShown,1489 },1490 };1491 }14921493 case "MARK_PASSKEY_BANNER_SHOWN": {1494 const { domain } = (msg.payload ?? {}) as { domain?: string };1495 if (typeof domain !== "string" || !domain) return { ok: false, error: "domain_required" };1496 1497 1498 const origin = attestedSenderOrigin(sender);1499 if (!origin || !isValidRpId(domain, origin)) return { ok: true };1500 if (chrome.storage.session) {1501 await chrome.storage.session.set({ [`peach_state11_shown:${domain}`]: Date.now() });1502 }1503 return { ok: true };1504 }15051506 case "DESTROY_VAULT": {1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 const preserved = await chrome.storage.local.get([1518 "peach_license",1519 "peach_pro_celebrated",1520 ]);1521 await vault.lock();1522 await storageClear();1523 await vault.clearVaultHistory();1524 const toRestore: Record<string, unknown> = {};1525 if (preserved.peach_license !== undefined) toRestore.peach_license = preserved.peach_license;1526 if (preserved.peach_pro_celebrated !== undefined) toRestore.peach_pro_celebrated = preserved.peach_pro_celebrated;1527 if (Object.keys(toRestore).length > 0) {1528 await chrome.storage.local.set(toRestore);1529 }1530 if (chrome.storage.session) {1531 await chrome.storage.session.clear();1532 }1533 await clearRecoveryConfirmPending();1534 return { ok: true };1535 }15361537 case "GET_IDENTITY_SUGGESTIONS": {1538 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1539 const entries = vault.getEntries();1540 const suggestions = buildIdentitySuggestions(entries);1541 return { ok: true, data: { suggestions } };1542 }1543 case "ML_INIT":1544 return { ok: true };15451546 case "ML_PREDICT_BATCH": {1547 1548 const { featuresArray } = (msg.payload ?? {}) as { featuresArray?: unknown };1549 if (!Array.isArray(featuresArray)) {1550 return { ok: false, error: "invalid_payload" };1551 }1552 return { ok: true, data: mlPredictBatch(featuresArray as MLFieldFeatures[]) };1553 }15541555 1556 1557 1558 1559 case "GET_LOCAL_IDENTITY": {1560 1561 1562 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1563 1564 1565 const masterKeyHex = vault.getRootKeyHex();1566 if (!masterKeyHex) return { ok: false, error: "vault_locked" };1567 const masterKey = unhexBytes(masterKeyHex);1568 try {1569 const identity = await buildLocalIdentity(masterKey);1570 1571 1572 if (await isProUser()) {1573 const license = await getLicenseKey();1574 if (license) (identity as { license?: string }).license = license;1575 }1576 return { ok: true, data: identity };1577 } finally {1578 masterKey.fill(0);1579 }1580 }15811582 case "LIST_PAIRED_DEVICES": {1583 const devices = await listPairedDevicesV3();1584 return { ok: true, data: { devices } };1585 }15861587 case "RENAME_PAIRED_DEVICE": {1588 const { deviceId, name } = (msg.payload ?? {}) as { deviceId?: string; name?: string };1589 if (typeof deviceId !== "string" || !deviceId || typeof name !== "string") {1590 return { ok: false, error: "invalid_payload" };1591 }1592 await renamePairedDeviceV3(deviceId, name);1593 return { ok: true };1594 }15951596 case "UNPAIR_DEVICE": {1597 const { deviceId } = (msg.payload ?? {}) as { deviceId?: string };1598 if (typeof deviceId !== "string" || !deviceId) {1599 return { ok: false, error: "invalid_payload" };1600 }1601 1602 1603 1604 await revokeDeviceByIdV3(deviceId, await getOrCreateDeviceId());1605 1606 1607 1608 const unpairRootHex = vault.getRootKeyHex();1609 if (unpairRootHex) {1610 const unpairRoot = unhexBytes(unpairRootHex);1611 try {1612 await dropPairSecret(deviceId, unpairRoot);1613 } finally {1614 unpairRoot.fill(0);1615 }1616 } else {1617 await dropPairSecret(deviceId, null);1618 }1619 1620 1621 void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);1622 return { ok: true };1623 }16241625 case "RENAME_LOCAL_DEVICE": {1626 const { name } = (msg.payload ?? {}) as { name?: string };1627 if (typeof name !== "string") return { ok: false, error: "invalid_payload" };1628 await setDeviceNameV3(name);1629 return { ok: true };1630 }16311632 1633 1634 1635 1636 case "START_ADOPTION_SOURCE": {1637 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1638 if (isRelayAdoptionSourceInFlight()) {1639 return { ok: false, error: "adoption_source_in_progress" };1640 }1641 try {1642 return { ok: true, data: await startRelayAdoptionSource() };1643 } catch (error) {1644 return {1645 ok: false,1646 error: error instanceof Error ? error.message : "adoption_source_failed",1647 };1648 }1649 }16501651 1652 1653 1654 1655 case "START_ADOPTION_RECEIVER": {1656 if (isRelayAdoptionSourceInFlight()) {1657 return { ok: false, error: "adoption_receiver_in_progress" };1658 }1659 const password = (msg.payload as { password?: unknown } | undefined)?.password;1660 if (typeof password !== "string" || password.length === 0) {1661 return { ok: false, error: "invalid_payload" };1662 }1663 try {1664 return { ok: true, data: await startRelayAdoptionReceiver(password) };1665 } catch (error) {1666 return {1667 ok: false,1668 error: error instanceof Error ? error.message : "adoption_receiver_failed",1669 };1670 }1671 }16721673 1674 1675 1676 case "RECEIVER_SAS_DECISION": {1677 const payload = msg.payload as { id?: unknown; sas?: unknown; ok?: unknown } | undefined;1678 if (typeof payload?.id !== "string" || typeof payload.sas !== "string" || typeof payload.ok !== "boolean") {1679 return { ok: false, error: "invalid_payload" };1680 }1681 return { ok: resolveReceiverSasConfirmation(payload.id, payload.sas, payload.ok) };1682 }16831684 case "SOURCE_SAS_DECISION": {1685 const payload = msg.payload as { id?: unknown; ok?: unknown } | undefined;1686 if (typeof payload?.id !== "string" || typeof payload?.ok !== "boolean") {1687 return { ok: false, error: "invalid_payload" };1688 }1689 return { ok: resolveSourceSasConfirmation(payload.id, payload.ok) };1690 }16911692 case "CANCEL_ADOPTION_SOURCE": {1693 cancelRelayAdoptionSource();1694 return { ok: true };1695 }16961697 case "TRIGGER_SYNC": {1698 const payload = (msg.payload ?? {}) as {1699 reason?: "vault_change" | "foreground" | "manual";1700 force?: boolean;1701 };1702 const reason = payload.reason ?? "foreground";17031704 void triggerSyncIfReachable({ reason, force: payload.force === true });1705 return { ok: true };1706 }17071708 case "GET_SYNC_STATE": {1709 return { ok: true, data: getSyncState() };1710 }17111712 case "GET_SYNC_SETTINGS": {1713 return { ok: true, data: await getSyncSettings() };1714 }17151716 case "SET_SYNC_SETTINGS": {1717 const payload = (msg.payload ?? {}) as {1718 mode?: unknown;1719 relayUrl?: unknown;1720 };1721 try {1722 1723 1724 if (payload.relayUrl !== undefined) {1725 if (typeof payload.relayUrl !== "string") {1726 return { ok: false, error: "invalid_relay_url" };1727 }1728 return { ok: true, data: await setRelayUrl(payload.relayUrl) };1729 }1730 if (payload.mode !== undefined) {1731 if (payload.mode !== "public" && payload.mode !== "private" && payload.mode !== "off") {1732 return { ok: false, error: "invalid_sync_mode" };1733 }1734 return { ok: true, data: await setSyncMode(payload.mode as SyncMode) };1735 }1736 return { ok: true, data: await getSyncSettings() };1737 } catch (error) {1738 return {1739 ok: false,1740 error: error instanceof Error ? error.message : "invalid_relay_url",1741 };1742 }1743 }17441745 case "GET_SYNC_ALERTS": {1746 const { consume } = (msg.payload ?? {}) as { consume?: boolean };1747 const alerts = await getPendingSyncAlerts();1748 1749 1750 1751 if (consume) await clearPendingSyncAlerts();1752 return { ok: true, data: alerts };1753 }17541755 case "GET_PENDING_VOUCHED_DEVICES": {1756 return { ok: true, data: await listPendingVouchedDevices() };1757 }1758 case "CONFIRM_VOUCHED_DEVICE": {1759 const { deviceId } = msg.payload as { deviceId: string };1760 const pending = await getPendingVouchedDevice(deviceId);1761 if (!pending) return { ok: false, error: "not_pending" };1762 1763 1764 1765 if (await isDeviceRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {1766 await removePendingVouchedDevice(deviceId);1767 return { ok: false, error: "device_revoked" };1768 }1769 await upsertPairedDeviceV3({1770 deviceId: pending.deviceId,1771 deviceName: pending.deviceName,1772 deviceType:1773 pending.deviceType as import("../shared/sync-types").PairedDevice["deviceType"],1774 longTermPublicKeyHex: pending.longTermPublicKeyHex,1775 signingPublicKeyHex: pending.signingPublicKeyHex,1776 wsPort: pending.wsPort,1777 pairedAt: pending.pairedAt,1778 trustSource: "vouched",1779 });1780 await removePendingVouchedDevice(deviceId);1781 1782 1783 void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);1784 return { ok: true };1785 }1786 case "REJECT_VOUCHED_DEVICE": {1787 const { deviceId } = msg.payload as { deviceId: string };1788 await removePendingVouchedDevice(deviceId);1789 return { ok: true };1790 }1791 case "ROTATE_SYNC_IDENTITY": {1792 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1793 const result = await vault.rotateSyncIdentity();1794 return result.ok ? { ok: true } : { ok: false, error: result.error };1795 }17961797 default:1798 return { ok: false, error: "unknown_message_type" };1799 }1800 } catch (err) {1801 return {1802 ok: false,1803 error: err instanceof Error ? err.message : "operation_failed",1804 };1805 }1806}18071808180918101811function buildIdentitySuggestions(1812 entries: VaultEntry[],1813): Array<{ email: string; username: string; count: number }> {1814 const usernameCounts = new Map<string, number>();1815 const usernameEmail = new Map<string, string>();1816 for (const entry of entries) {1817 const username = entry.username ?? "";1818 const email = (entry as VaultEntry & { email?: string }).email ?? "";1819 if (!username && !email) continue;1820 const key = username || email;1821 usernameCounts.set(key, (usernameCounts.get(key) ?? 0) + 1);1822 if (!usernameEmail.has(key)) usernameEmail.set(key, email);1823 }1824 return Array.from(usernameCounts.entries())1825 .sort((a, b) => b[1] - a[1])1826 .map(([username, count]) => ({1827 email: usernameEmail.get(username) ?? "",1828 username,1829 count,1830 }));1831}183218331834function broadcastToContentScripts(message: { type: string }): void {1835 if (!chrome.tabs?.query) return;1836 chrome.tabs.query({}, (tabs) => {1837 for (const tab of tabs) {1838 if (tab.id !== undefined) {1839 chrome.tabs.sendMessage(tab.id, message).catch(() => {1840 1841 });1842 }1843 }1844 });1845}18461847function shouldRefreshAutoLock(1848 type: MessageType,1849 sender: Partial<chrome.runtime.MessageSender>,1850): boolean {1851 if (!isExtensionPageSender(sender)) return false;1852 return (1853 type !== "HAS_VAULT" &&1854 type !== "IS_UNLOCKED" &&1855 type !== "GET_VAULT_STATUS" &&1856 type !== "LOCK" &&1857 type !== "UPDATE_SETTINGS" &&1858 type !== "GET_PENDING_PASSKEY" &&1859 type !== "GET_PASSKEY_PROVIDER_STATE" &&1860 type !== "DECLINE_CARD_SAVE" &&1861 type !== "IS_CARD_DECLINED" &&1862 type !== "AUTOFILL_QUERY" &&1863 type !== "ML_PREDICT_BATCH"1864 );1865}18661867function isAllowedCanaryWebhookUrl(value: string): boolean {1868 try {1869 const url = new URL(value);1870 if (url.protocol !== "https:") return false;18711872 const hostname = url.hostname.toLowerCase();1873 if (1874 hostname === "localhost" ||1875 hostname.endsWith(".localhost") ||1876 isPrivateIpv4(hostname) ||1877 isPrivateIpv6(hostname)1878 ) {1879 return false;1880 }18811882 return true;1883 } catch {1884 return false;1885 }1886}18871888function isPrivateIpv4(hostname: string): boolean {1889 if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {1890 return true;1891 }18921893 const octets = hostname.split(".").map((part) => Number(part));1894 if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {1895 return false;1896 }18971898 return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;1899}19001901function isPrivateIpv6(hostname: string): boolean {1902 const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();1903 1904 1905 1906 1907 1908 1909 const mapped = /^::ffff:(.+)$/.exec(normalized);1910 if (mapped) {1911 const tail = mapped[1] ?? "";1912 if (tail.includes(".")) return isPrivateIpv4(tail);1913 const parts = tail.split(":");1914 const [hi, lo] = parts;1915 if (1916 parts.length !== 2 ||1917 !hi ||1918 !lo ||1919 !/^[0-9a-f]{1,4}$/.test(hi) ||1920 !/^[0-9a-f]{1,4}$/.test(lo)1921 ) {1922 return true;1923 }1924 const octets = [1925 (Number.parseInt(hi, 16) >>> 8) & 0xff,1926 Number.parseInt(hi, 16) & 0xff,1927 (Number.parseInt(lo, 16) >>> 8) & 0xff,1928 Number.parseInt(lo, 16) & 0xff,1929 ];1930 return isPrivateIpv4(octets.join("."));1931 }1932 return (1933 normalized === "::1" ||1934 normalized.startsWith("fc") ||1935 normalized.startsWith("fd") ||1936 normalized.startsWith("fe80:")1937 );1938}