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