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