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