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