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 { isValidMLFeatureBatch, predictBatch as mlPredictBatch } from "./ml";71import {72 buildLocalIdentity,73 getOrCreateDeviceId,74 setDeviceName as setDeviceNameV3,75 type LocalDeviceIdentity,76} from "./device-identity";77import {78 isDeviceRevoked,79 listPairedDevices as listPairedDevicesV3,80 renamePairedDevice as renamePairedDeviceV3,81 revokeDeviceById as revokeDeviceByIdV3,82 TrustDataCorruptedError,83 upsertPairedDevice as upsertPairedDeviceV3,84} from "./paired-devices";85import { dropPairSecret } from "./pair-secrets";8687888990919293import {94 cancelRelayAdoptionSource,95 isRelayAdoptionSourceInFlight,96 resolveReceiverSasConfirmation,97 resolveSourceSasConfirmation,98 startRelayAdoptionReceiver,99 startRelayAdoptionSource,100} from "./relay-adoption-bg";101import {102 listPendingAdoptionHolders,103 removePendingAdoptionHolder,104} from "./pending-adoption-holder";105import {106 getSyncSettings,107 setRelayUrl,108 setSyncMode,109 type SyncMode,110} from "./sync-settings";111import {112 getSyncState,113 triggerSyncIfReachable,114} from "./sync-trigger";115import {116 browserGenerationIo,117 confirmSecureReset,118 enterResetRequired,119 resolveGeneration,120} from "./sync-generation";121import { isMessageSenderAllowed } from "./message-policy";122123const SUMMON_CAPABILITY_TTL_MS = 30_000;124type SummonCapability = { tabId: number; frameId: number; origin: string; operation: "inventory" | "fill"; expiresAt: number };125const summonCapabilities = new Map<string, SummonCapability>();126127export function createSummonCapability(tabId: number, origin: string, frameId = 0): string {128 const token = crypto.randomUUID();129 summonCapabilities.set(token, { tabId, frameId, origin, operation: "inventory", expiresAt: Date.now() + SUMMON_CAPABILITY_TTL_MS });130 return token;131}132133function consumeSummonCapability(token: unknown, sender: Partial<chrome.runtime.MessageSender>, operation: "inventory" | "fill"): boolean {134 if (typeof token !== "string") return false;135 const cap = summonCapabilities.get(token);136 if (!cap) return false;137 if (cap.expiresAt <= Date.now()) {138 summonCapabilities.delete(token);139 return false;140 }141 if (sender.tab?.id !== cap.tabId || (sender.frameId ?? 0) !== cap.frameId || attestedSenderOrigin(sender) !== cap.origin) return false;142 if (cap.operation !== operation) return false;143 if (operation === "inventory") cap.operation = "fill";144 else summonCapabilities.delete(token);145 return true;146}147148149150151152declare const __PEACH_E2E_PASSKEYS__: boolean;153const PASSKEY_E2E_PRO_BYPASS = __PEACH_E2E_PASSKEYS__ === true;154155function unhexBytes(hex: string): Uint8Array {156 if (hex.length % 2 !== 0) throw new Error("invalid hex");157 const out = new Uint8Array(hex.length / 2);158 for (let i = 0; i < out.length; i++) {159 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);160 }161 return out;162}163164165166167168169170async function requirePro(171 feature: ProFeature,172 trigger: "user" | "background" = "user"173): Promise<{ allowed: boolean }> {174 if (PASSKEY_E2E_PRO_BYPASS && feature === "passkeys") return { allowed: true };175 if (await isProUser()) return { allowed: true };176177 if (trigger === "user") {178 179 chrome.runtime180 .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })181 .catch(() => {182 183 });184 } else {185 186 187 188 if (chrome.storage.session) {189 chrome.storage.session190 .set({ pendingProNotice: { feature, triggeredAt: Date.now() } })191 .catch(() => {});192 }193 }194195 return { allowed: false };196}197198199const CANARY_MIN_INTERVAL_MS = 60 * 60 * 1000; 200201const PRIVATE_IPV4_RANGES = [202 { prefix: "10." },203 { prefix: "127." },204 { prefix: "169.254." },205 { prefix: "192.168." },206];207208export function setupMessageHandler(): void {209 chrome.runtime.onMessage.addListener(210 (msg: unknown, sender, sendResponse) => {211 if (!isMessage(msg)) {212 sendResponse({ ok: false, error: "invalid_message" });213 return true;214 }215 handleMessage(msg, sender)216 .then(sendResponse)217 .catch((err) =>218 sendResponse({219 ok: false,220 error: err instanceof Error ? err.message : String(err),221 })222 );223 return true;224 }225 );226}227228function isMessage(m: unknown): m is Message {229 return typeof m === "object" && m !== null && "type" in m;230}231232233234235236237238239240function attestedSenderOrigin(sender: Partial<chrome.runtime.MessageSender>): string | undefined {241 if (sender.origin) return sender.origin;242 if (sender.url) {243 try { return new URL(sender.url).origin; } catch { return undefined; }244 }245 return undefined;246}247248function isExtensionPageSender(sender: Partial<chrome.runtime.MessageSender>): boolean {249 return sender.url?.startsWith("chrome-extension://") === true ||250 sender.url?.startsWith("moz-extension://") === true;251}252253254function isPageSender(sender: Partial<chrome.runtime.MessageSender>): boolean {255 return !isExtensionPageSender(sender) &&256 (sender.tab !== undefined || sender.url !== undefined);257}258259function entryDomains(url: unknown, additionalUrls: unknown): Set<string> {260 const domains = new Set<string>();261 const primary = typeof url === "string" ? extractDomain(url) : null;262 if (primary) domains.add(primary);263 if (Array.isArray(additionalUrls)) {264 for (const extra of additionalUrls) {265 if (typeof extra !== "string") continue;266 const domain = extractDomain(extra);267 if (domain) domains.add(domain);268 }269 }270 return domains;271}272273274275276277278279280281282283284285286287288289290291292function senderMayBindEntryDomains(293 sender: Partial<chrome.runtime.MessageSender>,294 domains: Set<string>,295): boolean {296 if (isExtensionPageSender(sender)) return true;297 const domain = attestedSenderDomain(sender);298 if (domain === null) return false;299 for (const candidate of domains) {300 if (candidate !== domain) return false;301 }302 return true;303}304305function senderMayMutateEntryDomains(306 sender: Partial<chrome.runtime.MessageSender>,307 before: Set<string>,308 after: Set<string>,309): boolean {310 if (isExtensionPageSender(sender)) return true;311 const domain = attestedSenderDomain(sender);312 if (domain === null || !after.has(domain)) return false;313 for (const kept of after) {314 if (!before.has(kept)) return false;315 }316 return true;317}318319320321322323function attestedSenderDomain(sender: Partial<chrome.runtime.MessageSender>): string | null {324 if (sender.tab === undefined && sender.url === undefined) return null;325 const origin = attestedSenderOrigin(sender);326 if (!origin) return null;327 try { return extractDomain(new URL(origin).hostname); } catch { return null; }328}329330function patchChangesEntryOrigin(patch: object): boolean {331 return Object.hasOwn(patch, "url") || Object.hasOwn(patch, "additionalUrls");332}333334335function patchedUrlShape(336 patch: object,337 existing: Pick<VaultEntry, "url" | "additionalUrls">,338): { url: unknown; additionalUrls: unknown } {339 const candidate = patch as { url?: unknown; additionalUrls?: unknown };340 return {341 url: Object.hasOwn(patch, "url") ? candidate.url : existing.url,342 additionalUrls: Object.hasOwn(patch, "additionalUrls")343 ? candidate.additionalUrls344 : existing.additionalUrls,345 };346}347348349350351let restoreReady: Promise<void> = Promise.resolve();352const MAX_ML_BATCH_FIELDS = 128;353const MAX_ML_FIELDS_PER_SECOND = 256;354const mlWorkBySender = new Map<string, { startedAt: number; fields: number }>();355356function claimMLWork(sender: Partial<chrome.runtime.MessageSender>, fields: number): boolean {357 if (fields === 0) return true;358 const now = Date.now();359 for (const [key, value] of mlWorkBySender) {360 if (now - value.startedAt >= 1_000) mlWorkBySender.delete(key);361 }362 const key = `${sender.tab?.id ?? "ui"}:${sender.frameId ?? 0}:${attestedSenderOrigin(sender) ?? "extension"}`;363 const current = mlWorkBySender.get(key);364 if (!current) {365 mlWorkBySender.set(key, { startedAt: now, fields });366 return fields <= MAX_ML_FIELDS_PER_SECOND;367 }368 if (current.fields + fields > MAX_ML_FIELDS_PER_SECOND) return false;369 current.fields += fields;370 return true;371}372373374export function setRestoreReady(p: Promise<unknown>): void {375 restoreReady = p.then(() => undefined, () => undefined);376}377378export async function handleMessage(379 msg: Message,380 sender: Partial<chrome.runtime.MessageSender> = {}381): Promise<MessageResponse> {382 try {383 384 385 386 387 388 389 await restoreReady;390 391 392 393 394 if (395 sender.id !== undefined &&396 sender.id !== chrome.runtime.id397 ) {398 return { ok: false, error: "invalid_sender" };399 }400401 402 403 404 405 406 407 408 const extensionOrigin = sender.url && typeof chrome.runtime.getURL === "function"409 ? chrome.runtime.getURL("")410 : "";411 if (!isMessageSenderAllowed(msg.type, sender, chrome.runtime.id, extensionOrigin)) {412 return { ok: false, error: "invalid_sender" };413 }414415 if (shouldRefreshAutoLock(msg.type, sender) && vault.isUnlocked()) {416 touchAutoLock();417 }418419 switch (msg.type) {420 case "CREATE_VAULT": {421 const { password } = msg.payload as { password: string };422 423 424 425 426 if (typeof password !== "string" || password.length === 0) {427 return { ok: false, error: "invalid_payload" };428 }429 await vault.createVault(password);430 await setupAutoLock();431 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });432 onVaultUnlocked();433 return { ok: true };434 }435 case "CREATE_VAULT_FROM_ROOT_KEY": {436 const { password, rootKeyHex } = (msg.payload ?? {}) as {437 password?: unknown;438 rootKeyHex?: unknown;439 };440 if (typeof password !== "string" || password.length === 0 || typeof rootKeyHex !== "string") {441 return { ok: false, error: "invalid_payload" };442 }443444 try {445 const { vaultId } = await vault.createVaultFromRootKey(password, rootKeyHex);446 await setupAutoLock();447 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });448 onVaultUnlocked();449 return { ok: true, vaultId };450 } catch (err) {451 return { ok: false, error: err instanceof Error ? err.message : String(err) };452 }453 }454 case "UNLOCK": {455 const { password } = msg.payload as { password: string };456 const ok = await vault.unlock(password);457 if (ok) {458 459 460 const kat = await runSelfTest();461 if (!kat.ok) {462 console.error("[Peach] Crypto self-test FAILED:", kat.error);463 await vault.lock();464 return { ok: false, error: "self_test_failed" };465 }466 await vault.updateSettings({ pinPreferred: false });467 await setupAutoLock();468 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });469 onVaultUnlocked();470 return { ok: true };471 }472 473 474 475 const failure = vault.consumeLastUnlockFailureReason();476 if (failure) {477 return {478 ok: false,479 error: "vault_corrupted",480 canRestoreFromHistory: true,481 } as MessageResponse;482 }483 return { ok: false };484 }485 case "UNLOCK_WITH_PIN": {486 const { pin } = (msg.payload ?? {}) as { pin?: unknown };487 if (typeof pin !== "string") return { ok: false, error: "invalid_payload" };488 const ok = await vault.unlockWithPin(pin);489 if (!ok) {490 const status = await vault.getPinUnlockStatus();491 if (status.configured && !status.available) {492 return {493 ok: false,494 error: status.attemptsRemaining === 0495 ? "pin_attempts_exceeded"496 : "pin_session_expired",497 };498 }499 return { ok: false };500 }501 const kat = await runSelfTest();502 if (!kat.ok) {503 await vault.lock();504 return { ok: false, error: "self_test_failed" };505 }506 await vault.updateSettings({ pinPreferred: true });507 await setupAutoLock();508 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });509 onVaultUnlocked();510 return { ok: true };511 }512 case "GET_PIN_STATUS":513 return { ok: true, data: await vault.getPinUnlockStatus() };514 case "VALIDATE_RECOVERY_WORDS": {515 const { words } = msg.payload as { words: string[] };516 const invalid = findInvalidWords(words);517 return { ok: true, data: { valid: invalid.length === 0, invalid } };518 }519 case "GET_RECOVERY_WORDS": {520 521 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };522 523 524 525 526 527 528 529 530 if (!(await isRecoveryConfirmPending())) {531 const { password } = (msg.payload ?? {}) as { password?: string };532 if (typeof password !== "string" || !password) {533 return { ok: false, error: "password_required" };534 }535 if (!(await vault.verifyMainPassword(password))) {536 return { ok: false, error: "wrong_password" };537 }538 }539 try {540 const words = await vault.getRecoveryWords();541 return { ok: true, data: words };542 } catch {543 return { ok: false, error: "recovery_not_setup" };544 }545 }546 case "GET_RECOVERY_CONFIRM_PENDING":547 return { ok: true, data: await isRecoveryConfirmPending() };548 case "SET_RECOVERY_CONFIRM_PENDING":549 await setRecoveryConfirmPending();550 return { ok: true };551 case "CLEAR_RECOVERY_CONFIRM_PENDING":552 await clearRecoveryConfirmPending();553 return { ok: true };554 case "RECOVER_WITH_WORDS": {555 556 557 const { words, newPassword } = msg.payload as {558 words: string[];559 newPassword: string;560 };561 const stored = await vault.loadSlotA();562 if (!stored) return { ok: false, error: "no_vault" };563 if (!stored.recovery_salt || !stored.recovery_wrapped_root_key) {564 return { ok: false, error: "recovery_not_setup" };565 }566 567 try {568 await vault.unwrapRootKeyForRecovery(569 stored.recovery_wrapped_root_key,570 stored.recovery_nonce ?? "",571 stored.recovery_tag ?? "",572 stored.recovery_salt,573 words574 );575 } catch {576 return { ok: false, error: "invalid_recovery_words" };577 }578 579 const newSalt = vault.generateSalt();580 const newKey = await vault.deriveKey(newPassword, newSalt);581 582 583 await vault.rewrapRootKeyForPassword(newKey);584 585 await vault.changePasswordWithNewKey(newSalt, newKey);586 await setupAutoLock();587 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });588 return { ok: true };589 }590 case "LOCK":591 await vault.lock();592 return { ok: true };593 case "IS_UNLOCKED":594 return { ok: true, data: vault.isUnlocked() };595 case "GET_VAULT_STATUS":596 return { ok: true, unlocked: vault.isUnlocked() };597 case "HAS_VAULT":598 return { ok: true, data: await vault.hasVault() };599 case "GET_ENTRIES":600 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };601 return { ok: true, data: vault.getEntries() };602 case "EXPORT_ENTRIES": {603 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };604 const { password } = (msg.payload ?? {}) as { password?: unknown };605 if (typeof password !== "string" || !password) {606 return { ok: false, error: "password_required" };607 }608 if (!(await vault.verifyMainPassword(password))) {609 return { ok: false, error: "wrong_password" };610 }611 return { ok: true, data: vault.getEntries() };612 }613 case "ADD_ENTRY": {614 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };615 const payload = msg.payload;616 if (!payload || typeof payload !== "object" || Array.isArray(payload)) {617 return { ok: false, error: "invalid_payload" };618 }619 620 621 if (isPageSender(sender)) {622 const draft = payload as { url?: unknown; additionalUrls?: unknown };623 const domains = entryDomains(draft.url, draft.additionalUrls);624 if (domains.size > 0 && !senderMayBindEntryDomains(sender, domains)) {625 return { ok: false, error: "origin_binding_required" };626 }627 }628 const entry = await vault.addEntry(payload as Parameters<typeof vault.addEntry>[0]);629 return { ok: true, data: entry };630 }631 case "BATCH_ADD_ENTRIES": {632 const payload = msg.payload as { entries?: unknown } | null | undefined;633 if (!payload || typeof payload !== "object" || Array.isArray(payload)) {634 return { ok: false, error: "invalid_payload" };635 }636 if (!Array.isArray(payload.entries)) {637 return { ok: false, error: "invalid_payload" };638 }639 const added = await vault.batchAddEntries(payload.entries as Parameters<typeof vault.batchAddEntries>[0]);640 return { ok: true, data: added };641 }642 case "RESTORE_ENTRIES": {643 const payload = msg.payload as644 | { entries?: unknown; mode?: unknown; settings?: unknown }645 | null646 | undefined;647 if (!payload || typeof payload !== "object" || Array.isArray(payload)) {648 return { ok: false, error: "invalid_payload" };649 }650 if (!Array.isArray(payload.entries)) {651 return { ok: false, error: "invalid_payload" };652 }653 if (payload.mode !== "replace" && payload.mode !== "merge") {654 return { ok: false, error: "invalid_payload" };655 }656 const summary = await vault.restoreEntries(657 payload.entries as Parameters<typeof vault.restoreEntries>[0],658 payload.mode659 );660 if (payload.settings && typeof payload.settings === "object" && !Array.isArray(payload.settings)) {661 await vault.updateSettings(payload.settings as vault.VaultSettings);662 }663 return { ok: true, data: summary };664 }665 case "CLEAR_VAULT_ENTRIES": {666 await vault.clearVaultEntries("REPLACE_ALL");667 return { ok: true };668 }669 case "GET_VAULT_HISTORY": {670 671 672 673 if (vault.isDecoyMode()) return { ok: true, data: [] };674 const snapshots = await vault.getVaultHistory();675 return { ok: true, data: snapshots };676 }677 case "RESTORE_VAULT_SNAPSHOT": {678 679 680 if (vault.isDecoyMode()) return { ok: false, error: "vault_locked" };681 const { snapshotId } = msg.payload as { snapshotId: string };682 const result = await vault.restoreVaultSnapshot(snapshotId);683 if (!result.ok) return { ok: false, error: result.error };684 broadcastToContentScripts({ type: "VAULT_LOCKED" });685 return { ok: true };686 }687 case "COUNT_DUPLICATES": {688 return { ok: true, data: vault.countDuplicates() };689 }690 case "DEDUPLICATE_ENTRIES": {691 const removed = await vault.deduplicateEntries();692 return { ok: true, data: removed };693 }694 case "UPDATE_ENTRY": {695 const payload = msg.payload as { id?: unknown; patch?: unknown } | null | undefined;696 if (!payload || typeof payload !== "object" || Array.isArray(payload)) {697 return { ok: false, error: "invalid_payload" };698 }699 const { id, patch } = payload;700 if (typeof id !== "string" || !id || !patch || typeof patch !== "object" || Array.isArray(patch)) {701 return { ok: false, error: "invalid_payload" };702 }703 const existingEntry = vault.getEntryById(id);704 if (!existingEntry) return { ok: false, error: "entry_not_found" };705 if (patchChangesEntryOrigin(patch) || isPageSender(sender)) {706 const before = entryDomains(existingEntry.url, existingEntry.additionalUrls);707 const after = patchedUrlShape(patch, existingEntry);708 if (!senderMayMutateEntryDomains(709 sender,710 before,711 entryDomains(after.url, after.additionalUrls),712 )) {713 return { ok: false, error: "origin_binding_required" };714 }715 }716 717 718 719 720 const incomingCanary = (patch as { canary?: unknown }).canary;721 if (incomingCanary && typeof incomingCanary === "object") {722 const existing = vault.getEntryById(id);723 if (!existing?.canary) {724 const gate = await requirePro("canary_entry");725 if (!gate.allowed) return { ok: false, error: "pro_required" };726 }727 }728 await vault.updateEntry(id, patch as Parameters<typeof vault.updateEntry>[1]);729 return { ok: true };730 }731 case "ADD_ADDITIONAL_URL": {732 const { id, url } = msg.payload as { id: string; url: string };733 const trimmed = typeof url === "string" ? url.trim() : "";734 if (!trimmed) return { ok: false, error: "url_required" };735 const existing = vault.getEntryById(id);736 if (!existing) return { ok: false, error: "entry_not_found" };737 const merged = [...(existing.additionalUrls ?? []), trimmed];738 if (!senderMayMutateEntryDomains(739 sender,740 entryDomains(existing.url, existing.additionalUrls),741 entryDomains(existing.url, merged),742 )) {743 return { ok: false, error: "origin_binding_required" };744 }745 if (existing.type !== "login") return { ok: false, error: "login_entries_only" };746 await vault.updateEntry(id, { additionalUrls: merged });747 return { ok: true };748 }749 case "REQUEST_REMEMBER_SITE": {750 751 752 753 754 const { id, url } = (msg.payload ?? {}) as { id?: unknown; url?: unknown };755 if (typeof id !== "string" || !id) return { ok: false, error: "invalid_payload" };756 const trimmed = typeof url === "string" ? url.trim() : "";757 if (!trimmed) return { ok: false, error: "url_required" };758 if (!senderMayBindEntryDomains(sender, entryDomains(trimmed, undefined))) {759 return { ok: false, error: "origin_binding_required" };760 }761 if (!vault.getEntryById(id)) return { ok: false, error: "entry_not_found" };762 if (chrome.storage.session) {763 await chrome.storage.session.set({ pendingRememberSite: { entryId: id, url: trimmed } });764 }765 try { await chrome.action.openPopup(); } catch { }766 return { ok: true };767 }768 case "DELETE_ENTRY": {769 const { id } = msg.payload as { id: string };770 await vault.deleteEntry(id);771 return { ok: true };772 }773 case "GET_DELETED_ENTRIES":774 return { ok: true, data: vault.getDeletedEntries() };775 case "RESTORE_ENTRY": {776 const { id } = msg.payload as { id: string };777 await vault.restoreEntry(id);778 return { ok: true };779 }780 case "PURGE_ENTRY": {781 const { id } = msg.payload as { id: string };782 await vault.purgeEntry(id);783 return { ok: true };784 }785 case "PURGE_EXPIRED_TRASH": {786 const { retentionMs } = msg.payload as { retentionMs: number };787 const purged = await vault.purgeExpiredTrash(retentionMs);788 return { ok: true, data: purged };789 }790 case "SAVE_NOTE": {791 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };792 const { id, title, body } = msg.payload as { id?: string; title: string; body: string };793 const now = Date.now();794 if (id) {795 const existing = vault.getEntryById(id);796 if (!existing) return { ok: false, error: "not_found" };797 await vault.updateEntry(id, { title: title || "Untitled", notes: body, modifiedAt: now });798 return { ok: true, data: vault.getEntryById(id) };799 } else {800 801 802 803 const entry = await vault.addEntry({804 type: "note",805 title: title || "Untitled",806 notes: body,807 });808 return { ok: true, data: entry };809 }810 }811 case "DELETE_NOTE": {812 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };813 const { id } = msg.payload as { id: string };814 await vault.deleteEntry(id);815 return { ok: true };816 }817 case "GET_NOTES": {818 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };819 const notes = vault.getEntries().filter(820 (e) => e.type === "note" && !e.deletedAt821 );822 return { ok: true, data: notes } as MessageResponse;823 }824 case "SEARCH_VAULT": {825 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };826 827 828 const { query } = (msg.payload ?? {}) as { query?: unknown };829 if (typeof query !== "string") {830 return { ok: false, error: "invalid_payload" };831 }832 const q = query.toLowerCase();833 const searchResults = vault.getEntries().filter(834 (e) =>835 !e.deletedAt &&836 (e.title.toLowerCase().includes(q) ||837 e.notes?.toLowerCase().includes(q) ||838 e.username?.toLowerCase().includes(q))839 );840 return { ok: true, data: searchResults } as MessageResponse;841 }842 case "AUTOFILL_QUERY": {843 if (!vault.isUnlocked()) {844 return { ok: false, error: "vault_locked" };845 }846 const { domain, kind, capability } = msg.payload as {847 domain: string;848 kind?: "login" | "card" | "totp";849 capability?: string;850 };851 const includeAll = consumeSummonCapability(capability, sender, "inventory");852 if (capability !== undefined && !includeAll) {853 return { ok: false, error: "entry_not_found" };854 }855 856 857 858 859 if (kind === "card") {860 if (!includeAll) return { ok: true, data: [] };861 const cards = vault.getEntries().filter((e) => e.type === "card");862 return {863 ok: true,864 data: cards.map((e) => ({865 id: e.id,866 title: e.cardNickname || e.title,867 cardholderName: e.cardholderName,868 last4: e.last4,869 expMonth: e.expMonth,870 expYear: e.expYear,871 network: e.network,872 })),873 };874 }875 const scopedDomain = isPageSender(sender)876 ? attestedSenderDomain(sender)877 : typeof domain === "string"878 ? extractDomain(domain)879 : null;880 if (!includeAll && !scopedDomain) {881 return isPageSender(sender)882 ? { ok: false, error: "origin_unverified" }883 : { ok: true, data: [] };884 }885 if (kind === "totp") {886 const matches = (887 includeAll888 ? vault.getEntries().filter((entry) => entry.type !== "note" && entry.type !== "card")889 : vault.queryDomain(scopedDomain!)890 ).filter((entry) => !isPasskeyOnly(entry) && Boolean(entry.totp));891 return {892 ok: true,893 data: matches.map((e) => ({894 id: e.id,895 title: e.title,896 username: e.username,897 email: e.email,898 url: e.url,899 hasTotp: true,900 })),901 };902 }903 904 905 906 907 908 const matches = (909 includeAll910 ? vault911 .getEntries()912 .filter((entry) => entry.type !== "note" && entry.type !== "card")913 : vault.queryDomain(scopedDomain!)914 ).filter((entry) => !isPasskeyOnly(entry));915 return {916 ok: true,917 data: matches.map((e) =>918 includeAll919 ? {920 id: e.id,921 title: e.title,922 923 924 925 926 927 username: e.username,928 email: e.email,929 url: e.url,930 hasPasskey: e.passkey !== undefined,931 }932 : {933 id: e.id,934 title: e.title,935 username: e.username,936 email: e.email,937 url: e.url,938 hasTotp: Boolean(e.totp),939 hasPasskey: e.passkey !== undefined,940 }941 ),942 };943 }944 case "GET_ENTRY_FOR_FILL": {945 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };946 const { id, capability } = msg.payload as {947 id: string;948 capability?: string;949 };950 const capabilityAuthorized = capability !== undefined && consumeSummonCapability(capability, sender, "fill");951 const senderDomain = isPageSender(sender) ? attestedSenderDomain(sender) : null;952 if (isPageSender(sender) && !senderDomain) {953 return { ok: false, error: "origin_unverified" };954 }955 const entry = vault.getEntryById(id);956 if (!entry) return { ok: false, error: "entry_not_found" };957958 959 960 961 962 963 964 965 if (senderDomain && !capabilityAuthorized) {966 if (!entryDomains(entry.url, entry.additionalUrls).has(senderDomain)) {967 968 return { ok: false, error: "entry_not_found" };969 }970 }971 972 973 974 if (isPasskeyOnly(entry)) return { ok: false, error: "passkey_not_fillable" };975 976 977 978 979 980 981 982 983 984 985 const {986 passkey: _strippedPasskey,987 totp: seed,988 totpAlgorithm,989 totpDigits,990 totpPeriod,991 passwordHistory: _ph,992 ...safeEntry993 } = entry;994 let totpCode: string | undefined;995 let totpRemaining: number | undefined;996 if (seed) {997 try {998 999 1000 const generated = generateTOTP(seed, totpAlgorithm, totpDigits, totpPeriod);1001 totpCode = generated.code;1002 totpRemaining = generated.remaining;1003 } catch {1004 totpCode = undefined;1005 }1006 }1007 if (isPageSender(sender) && vault.isUnlocked()) touchAutoLock();1008 return { ok: true, data: { ...safeEntry, totpCode, totpRemaining } };1009 }1010 1011 1012 case "GET_ENTRY_FOR_EDIT": {1013 if (isPageSender(sender)) return { ok: false, error: "invalid_sender" };1014 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1015 const { id } = msg.payload as { id: string };1016 const entry = vault.getEntryById(id);1017 if (!entry) return { ok: false, error: "entry_not_found" };10181019 const { passkey: _passkey, ...safeEntry } = entry;1020 return { ok: true, data: safeEntry };1021 }1022 case "COPY": {1023 1024 1025 1026 if (isPageSender(sender) && !attestedSenderOrigin(sender)) {1027 return { ok: false, error: "invalid_sender" };1028 }1029 1030 1031 1032 const { text, label, alreadyWritten } = (msg.payload ?? {}) as {1033 text?: unknown;1034 label?: unknown;1035 alreadyWritten?: unknown;1036 };1037 if (typeof text !== "string" || typeof label !== "string") {1038 return { ok: false, error: "invalid_payload" };1039 }1040 await copyToClipboard(text, label, Boolean(alreadyWritten));1041 return { ok: true, clearSeconds: getEffectiveClearSeconds() };1042 }1043 case "CHECK_ALL_BREACHES": {1044 const allEntries = vault.getEntries();1045 const results: Record<string, number> = {};1046 for (const entry of allEntries) {1047 if (entry.type === "note") continue;1048 if (!entry.password) continue;1049 results[entry.id] = await vault.checkBreach(entry.password);1050 1051 await new Promise((r) => setTimeout(r, 200));1052 }1053 1054 for (const [id, count] of Object.entries(results)) {1055 const entry = vault.getEntryById(id);1056 if (entry && (entry.breach ?? 0) !== count) {1057 await vault.updateEntry(id, { breach: count > 0 ? count : undefined });1058 }1059 }1060 return { ok: true, data: results };1061 }1062 case "GET_SETTINGS": {1063 const settings = await vault.ensureSettingsLoaded();1064 if (!isPageSender(sender)) return { ok: true, data: settings };10651066 const safeSettings: Pick<vault.VaultSettings, "accentColor" | "theme"> & {1067 manualSummonDomains?: NonNullable<vault.VaultSettings["manualSummonDomains"]>;1068 } = {1069 accentColor: settings.accentColor,1070 theme: settings.theme,1071 };1072 const domain = attestedSenderDomain(sender);1073 const ownSummon = domain ? settings.manualSummonDomains?.[domain] : undefined;1074 if (domain && ownSummon) safeSettings.manualSummonDomains = { [domain]: ownSummon };1075 return { ok: true, data: safeSettings };1076 }1077 case "UPDATE_SETTINGS": {1078 const patch = msg.payload;1079 if (!patch || typeof patch !== "object" || Array.isArray(patch)) {1080 return { ok: false, error: "invalid_payload" };1081 }1082 await vault.updateSettings(patch as vault.VaultSettings);1083 if (typeof (patch as Record<string, unknown>).autoLockMinutes === "number") {1084 await setupAutoLock();1085 if (vault.isUnlocked()) touchAutoLock();1086 }1087 return { ok: true };1088 }1089 case "SETUP_PIN": {1090 const { currentPassword, pin } = (msg.payload ?? {}) as {1091 currentPassword?: unknown;1092 pin?: unknown;1093 };1094 if (typeof currentPassword !== "string" || typeof pin !== "string") {1095 return { ok: false, error: "invalid_payload" };1096 }1097 return (await vault.setupPinUnlock(currentPassword, pin))1098 ? { ok: true }1099 : { ok: false, error: "incorrect_current_password" };1100 }1101 case "DISABLE_PIN": {1102 const { currentPassword } = (msg.payload ?? {}) as { currentPassword?: unknown };1103 if (typeof currentPassword !== "string") return { ok: false, error: "invalid_payload" };1104 return (await vault.disablePinUnlock(currentPassword))1105 ? { ok: true }1106 : { ok: false, error: "incorrect_current_password" };1107 }1108 case "GET_PENDING_PASSKEY":1109 return { ok: true, data: await getPendingPasskeyRequest() };1110 case "APPROVE_PASSKEY": {1111 const { requestId } = msg.payload as { requestId: number };1112 await resolvePendingPasskeyRequest(requestId, true);1113 return { ok: true };1114 }1115 case "DENY_PASSKEY": {1116 const { requestId } = msg.payload as { requestId: number };1117 await resolvePendingPasskeyRequest(requestId, false);1118 return { ok: true };1119 }1120 case "GET_PASSKEY_PROVIDER_STATE":1121 return { ok: true, data: await getPasskeyProviderState() };1122 case "SET_PASSKEY_PROVIDER_ENABLED": {1123 const { enabled } = msg.payload as { enabled: boolean };1124 1125 1126 if (enabled) {1127 const gate = await requirePro("passkeys");1128 if (!gate.allowed) return { ok: false, error: "pro_required" };1129 }1130 return { ok: true, data: await setPasskeyProviderEnabled(enabled) };1131 }1132 1133 1134 1135 1136 1137 1138 case "FIRE_CANARY_PING": {1139 1140 1141 1142 1143 const { webhookUrl, entryTitle, trigger } = msg.payload as {1144 webhookUrl: string;1145 entryTitle: string;1146 trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";1147 };1148 if (trigger === "test" && !isExtensionPageSender(sender)) {1149 return { ok: false, error: "invalid_sender" };1150 }1151 if (!isAllowedCanaryWebhookUrl(webhookUrl)) {1152 return { ok: false, error: "invalid_webhook_url" };1153 }1154 1155 1156 1157 1158 1159 1160 1161 if (trigger !== "test") {1162 const keyDigest = new Uint8Array(1163 await crypto.subtle.digest(1164 "SHA-256",1165 new TextEncoder().encode(`${webhookUrl}:${entryTitle}`),1166 ),1167 );1168 const keySuffix = Array.from(keyDigest)1169 .map((byte) => byte.toString(16).padStart(2, "0"))1170 .join("");1171 const rlKey = `canary-last-fetch:${keySuffix}`;1172 const legacyRlKey = `canary-last-fetch:${webhookUrl}:${entryTitle}`;1173 const stored = await chrome.storage.local.get(rlKey);1174 await chrome.storage.local.remove(legacyRlKey);1175 const last = (stored as Record<string, unknown>)[rlKey];1176 if (typeof last === "number" && Date.now() - last < CANARY_MIN_INTERVAL_MS) {1177 return { ok: true, data: { rateLimited: true } };1178 }1179 await chrome.storage.local.set({ [rlKey]: Date.now() });1180 }1181 const body = JSON.stringify({1182 event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",1183 entry: entryTitle,1184 trigger,1185 timestamp: new Date().toISOString(),1186 source: "peach-extension",1187 });1188 if (trigger === "test") {1189 1190 1191 1192 1193 1194 try {1195 const res = await fetch(webhookUrl, {1196 method: "POST",1197 headers: { "Content-Type": "application/json" },1198 body,1199 });1200 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };1201 } catch (err) {1202 return { ok: false, error: err instanceof Error ? err.message : "network_error" };1203 }1204 }1205 1206 fetch(webhookUrl, {1207 method: "POST",1208 headers: { "Content-Type": "application/json" },1209 body,1210 }).catch(() => {});1211 return { ok: true };1212 }1213 case "ACTIVATE_LICENSE": {1214 const { key } = msg.payload as { key: string };1215 const licenseInfo = await activateLicense(key);1216 return { ok: true, data: licenseInfo };1217 }1218 case "GET_LICENSE": {1219 const licenseInfo = await getLicenseInfo();1220 return { ok: true, data: licenseInfo };1221 }1222 case "GET_LICENSE_KEY": {1223 const key = await getLicenseKey();1224 return { ok: true, data: key };1225 }1226 case "DEACTIVATE_LICENSE": {1227 await deactivateLicense();1228 return { ok: true };1229 }1230 case "SHOULD_PLAY_CELEBRATION": {1231 const should = await shouldPlayCelebration();1232 return { ok: true, data: should };1233 }1234 case "MARK_CELEBRATION_PLAYED": {1235 await markCelebrationPlayed();1236 return { ok: true };1237 }1238 case "GET_CODEX_VAULT_DATA": {1239 1240 1241 1242 1243 const gate = await requirePro("codex");1244 if (!gate.allowed) return { ok: false, error: "pro_required" };1245 const { password } = (msg.payload ?? {}) as { password?: string };1246 return await handleGetCodexVaultData(password);1247 }1248 case "ESTIMATE_CODEX_PAGES": {1249 const gate = await requirePro("codex");1250 if (!gate.allowed) return { ok: false, error: "pro_required" };1251 return await handleEstimateCodexPages();1252 }1253 case "RESTORE_FROM_CODEX": {1254 1255 1256 1257 1258 1259 1260 if (vault.isDecoyMode()) return { ok: false, error: "vault_locked" };1261 const payload = msg.payload as CodexRestorePayload;1262 return await handleRestoreFromCodex(payload);1263 }1264 case "OPEN_PRO_GATE": {1265 1266 const { feature } = (msg.payload ?? {}) as { feature?: string };1267 if (chrome.storage.session) {1268 await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });1269 }1270 try { await chrome.action.openPopup(); } catch { }1271 return { ok: true };1272 }1273 case "OPEN_POPUP": {1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 const { entryId } = (msg.payload ?? {}) as { entryId?: unknown };1284 if (entryId !== undefined && (typeof entryId !== "string" || entryId.length === 0 || entryId.length > 64)) {1285 return { ok: false, error: "invalid_payload" };1286 }1287 if (chrome.storage.session) {1288 if (typeof entryId === "string") {1289 await chrome.storage.session.set({ peach_open_entry: entryId });1290 } else {1291 await chrome.storage.session.set({ peach_inline_unlock: true });1292 }1293 }1294 try { await chrome.action.openPopup(); } catch { }1295 return { ok: true };1296 }1297 case "OPEN_DETACHED_POPUP": {1298 1299 1300 1301 1302 1303 1304 const { route } = (msg.payload ?? {}) as { route?: string };1305 const search = route === "peachscript"1306 ? "?restore=peachscript"1307 : route === "csv"1308 ? "?restore=csv"1309 : route === "codex-pdf"1310 ? "?codex=pdf"1311 : null;1312 if (!search) return { ok: false, error: "bad_route" };1313 try {1314 const popupHtml =1315 chrome.runtime.getManifest().action?.default_popup ?? "src/popup/index.html";1316 await chrome.windows.create({1317 url: chrome.runtime.getURL(`${popupHtml}${search}`),1318 type: "popup",1319 width: 320,1320 height: 550,1321 });1322 return { ok: true, data: { detached: true } };1323 } catch {1324 return { ok: false };1325 }1326 }1327 case "GET_IDENTITIES": {1328 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1329 return { ok: true, data: vault.getIdentities() };1330 }1331 case "TOUCH_AUTO_LOCK": {1332 1333 1334 return { ok: true };1335 }1336 case "GET_ADVANCED_HEALTH_REPORT": {1337 const gate = await requirePro("advanced_health");1338 if (!gate.allowed) return { ok: false, error: "pro_required" };1339 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1340 const report = await buildAdvancedHealthReport(vault.getEntries());1341 return { ok: true, data: report };1342 }1343 case "IS_DURESS_ENABLED": {1344 const enabled = await vault.isDuressEnabled();1345 return { ok: true, data: enabled };1346 }1347 case "SETUP_DURESS": {1348 1349 1350 1351 1352 const gate = await requirePro("duress_vault");1353 if (!gate.allowed) return { ok: false, error: "pro_required" };1354 1355 1356 1357 if (!vault.isUnlocked()) return { ok: false, error: "session_expired" };1358 const { duressPassword } = msg.payload as { duressPassword: string };1359 await vault.setupDuress(duressPassword);1360 return { ok: true };1361 }1362 case "DISABLE_DURESS": {1363 await vault.disableDuress();1364 return { ok: true };1365 }1366 case "VERIFY_MAIN_PASSWORD": {1367 const { password } = msg.payload as { password: string };1368 const ok = await vault.verifyMainPassword(password);1369 return { ok: true, data: ok };1370 }1371 case "CHANGE_PASSWORD": {1372 const { currentPassword, newPassword } = msg.payload as {1373 currentPassword: string;1374 newPassword: string;1375 };1376 const changed = await vault.changePassword(currentPassword, newPassword);1377 return changed1378 ? { ok: true }1379 : { ok: false, error: "incorrect_current_password" };1380 }1381 case "GET_SITE_PASSWORD_POLICY": {1382 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });1383 if (!tab?.id) return { ok: true, data: null };1384 try {1385 const response = await chrome.tabs.sendMessage(tab.id, {1386 type: "GET_SITE_PASSWORD_POLICY",1387 });1388 return (response as MessageResponse) ?? { ok: true, data: null };1389 } catch {1390 return { ok: true, data: null };1391 }1392 }1393 case "RECORD_MANUAL_SUMMON": {1394 const { domain } = (msg.payload ?? {}) as { domain?: unknown };1395 const origin = attestedSenderOrigin(sender);1396 let senderDomain: string | null = null;1397 if (origin) {1398 try {1399 const hostname = new URL(origin).hostname;1400 senderDomain = extractDomain(hostname) ?? hostname;1401 } catch {1402 senderDomain = null;1403 }1404 }1405 if (typeof domain !== "string" || domain.length > 253 || domain !== senderDomain) {1406 return { ok: false, error: "invalid_domain" };1407 }1408 const settings = vault.getSettings();1409 const existing = settings.manualSummonDomains?.[domain];1410 const now = Date.now();1411 await vault.updateSettings({1412 manualSummonDomains: {1413 ...settings.manualSummonDomains,1414 [domain]: {1415 firstSummonedAt: existing?.firstSummonedAt ?? now,1416 summonCount: (existing?.summonCount ?? 0) + 1,1417 lastSummonedAt: now,1418 },1419 },1420 });1421 return { ok: true };1422 }1423 case "DECLINE_CARD_SAVE": {1424 1425 1426 1427 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1428 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1429 await vault.declineCardSaveOffer(last4, expMonth, expYear);1430 return { ok: true };1431 }1432 case "IS_CARD_DECLINED": {1433 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1434 const declined = await vault.isCardDeclined(last4, expMonth, expYear);1435 return { ok: true, data: declined };1436 }1437 case "INITIATE_FIREFOX_PASSKEY_CREATE": {1438 const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {1439 rpId?: string;1440 rpName?: string;1441 userName?: string;1442 userDisplayName?: string;1443 };1444 if (!rpId || !userName) return { ok: false, error: "rpid_and_username_required" };1445 return await initiateFirefoxPasskeyCreate({1446 rpId,1447 rpName: rpName ?? rpId,1448 userName,1449 userDisplayName,1450 }, attestedSenderOrigin(sender));1451 }14521453 case "COMPLETE_FIREFOX_PASSKEY_CREATE": {1454 const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;1455 if (!payload) return { ok: false, error: "missing_payload" };1456 return await completeFirefoxPasskeyCreate(payload);1457 }14581459 case "PASSKEY_OFFER_DECLINE": {1460 const { domain } = (msg.payload ?? {}) as { domain?: string };1461 const origin = attestedSenderOrigin(sender);1462 if (!domain || !origin || !isValidRpId(domain, origin)) return { ok: false, error: "invalid_domain" };1463 await recordPasskeyOfferDecline(domain);1464 return { ok: true };1465 }14661467 case "GET_PASSKEY_OFFER_DECLINED": {1468 const { domain } = (msg.payload ?? {}) as { domain?: string };1469 const origin = attestedSenderOrigin(sender);1470 if (!domain || !origin || !isValidRpId(domain, origin)) return { ok: false, error: "invalid_domain" };1471 return { ok: true, data: await isPasskeyOfferDeclined(domain) };1472 }14731474 case "PASSKEY_HAS_CREDENTIALS": {1475 const payload = msg.payload as {1476 rpId?: string;1477 allowCredentials?: Array<{ id: string; type?: string }>;1478 } | undefined;1479 if (!payload?.rpId) return { ok: false, error: "rpid_required" };1480 return await handlePasskeyHasCredentials({1481 rpId: payload.rpId,1482 allowCredentials: payload.allowCredentials ?? [],1483 }, attestedSenderOrigin(sender));1484 }14851486 case "PASSKEY_CREATE": {1487 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;1488 if (!payload?.rpId || !payload?.userName) {1489 return { ok: false, error: "rpid_and_username_required" };1490 }1491 return await handleFirefoxPasskeyCreate(payload, attestedSenderOrigin(sender));1492 }14931494 case "PASSKEY_GET": {1495 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;1496 if (!payload?.rpId) return { ok: false, error: "rpid_required" };1497 return await handleFirefoxPasskeyGet(payload, attestedSenderOrigin(sender));1498 }14991500 case "PASSKEY_AUTH_RESPONSE": {1501 const { requestId, approved } = (msg.payload ?? {}) as {1502 requestId?: string | number;1503 approved?: boolean;1504 };1505 if (requestId === undefined || requestId === null || typeof approved !== 'boolean') {1506 return { ok: false, error: 'requestId and approved required' };1507 }1508 const pending = await getPendingPasskeyRequest();1509 const senderOrigin = attestedSenderOrigin(sender);1510 if (1511 (pending && pending.requestId !== Number(requestId)) ||1512 (pending?.tabId !== undefined && sender.tab?.id !== pending.tabId) ||1513 (pending?.origin !== undefined && senderOrigin !== pending.origin)1514 ) {1515 return { ok: false, error: "invalid_passkey_approval_sender" };1516 }1517 await resolvePendingPasskeyRequest(Number(requestId), approved);1518 return { ok: true };1519 }15201521 case "GET_PASSKEY_FOR_DOMAIN": {1522 const { domain } = (msg.payload ?? {}) as { domain?: string };1523 if (!domain) return { ok: false, error: "domain_required" };1524 1525 1526 1527 const origin = attestedSenderOrigin(sender);1528 if (!origin || !isValidRpId(domain, origin)) return { ok: true, data: null };1529 if (!vault.isUnlocked()) return { ok: true, data: null };1530 const match = vault1531 .getEntries()1532 .find((entry) => entry.passkey?.rpId === domain);1533 if (!match || !match.passkey) return { ok: true, data: null };1534 1535 1536 1537 1538 1539 let bannerShown = false;1540 if (chrome.storage.session) {1541 const flag = await chrome.storage.session.get(`peach_state11_shown:${domain}`);1542 bannerShown = Boolean((flag as Record<string, unknown>)[`peach_state11_shown:${domain}`]);1543 }1544 return {1545 ok: true,1546 data: {1547 rpId: match.passkey.rpId,1548 userName: match.passkey.userName,1549 browser: match.passkey.browser,1550 bannerShown,1551 },1552 };1553 }15541555 case "MARK_PASSKEY_BANNER_SHOWN": {1556 const { domain } = (msg.payload ?? {}) as { domain?: string };1557 if (typeof domain !== "string" || !domain) return { ok: false, error: "domain_required" };1558 1559 1560 const origin = attestedSenderOrigin(sender);1561 if (!origin || !isValidRpId(domain, origin)) return { ok: true };1562 if (chrome.storage.session) {1563 await chrome.storage.session.set({ [`peach_state11_shown:${domain}`]: Date.now() });1564 }1565 return { ok: true };1566 }15671568 case "DESTROY_VAULT": {1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 const preserved = await chrome.storage.local.get([1580 "peach_license",1581 "peach_pro_celebrated",1582 ]);1583 await vault.lock();1584 await storageClear();1585 await vault.clearVaultHistory();1586 const toRestore: Record<string, unknown> = {};1587 if (preserved.peach_license !== undefined) toRestore.peach_license = preserved.peach_license;1588 if (preserved.peach_pro_celebrated !== undefined) toRestore.peach_pro_celebrated = preserved.peach_pro_celebrated;1589 if (Object.keys(toRestore).length > 0) {1590 await chrome.storage.local.set(toRestore);1591 }1592 if (chrome.storage.session) {1593 await chrome.storage.session.clear();1594 }1595 await clearRecoveryConfirmPending();1596 return { ok: true };1597 }15981599 case "GET_IDENTITY_SUGGESTIONS": {1600 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1601 const entries = vault.getEntries();1602 const suggestions = buildIdentitySuggestions(entries);1603 return { ok: true, data: { suggestions } };1604 }1605 case "ML_INIT":1606 return { ok: true };16071608 case "ML_PREDICT_BATCH": {1609 const { featuresArray } = (msg.payload ?? {}) as { featuresArray?: unknown };1610 if (!isValidMLFeatureBatch(featuresArray, MAX_ML_BATCH_FIELDS)) {1611 return { ok: false, error: "invalid_payload" };1612 }1613 if (!claimMLWork(sender, featuresArray.length)) {1614 return { ok: false, error: "rate_limited" };1615 }1616 return { ok: true, data: mlPredictBatch(featuresArray) };1617 }16181619 1620 1621 1622 1623 case "GET_LOCAL_IDENTITY": {1624 1625 1626 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1627 1628 1629 const identity: LocalDeviceIdentity & { license?: string } = await buildLocalIdentity();1630 1631 1632 if (await isProUser()) {1633 const license = await getLicenseKey();1634 if (license) identity.license = license;1635 }1636 return { ok: true, data: identity };1637 }16381639 case "LIST_PAIRED_DEVICES": {1640 if (vault.isDecoyMode()) return { ok: true, data: { devices: [] } };1641 try {1642 const devices = await listPairedDevicesV3();1643 return { ok: true, data: { devices } };1644 } catch (err) {1645 if (err instanceof TrustDataCorruptedError) {1646 return { ok: false, error: "trust_data_corrupted" };1647 }1648 throw err;1649 }1650 }16511652 case "RENAME_PAIRED_DEVICE": {1653 if (vault.isDecoyMode()) return { ok: false, error: "vault_locked" };1654 const { deviceId, name } = (msg.payload ?? {}) as { deviceId?: string; name?: string };1655 if (typeof deviceId !== "string" || !deviceId || typeof name !== "string") {1656 return { ok: false, error: "invalid_payload" };1657 }1658 await renamePairedDeviceV3(deviceId, name);1659 return { ok: true };1660 }16611662 case "UNPAIR_DEVICE": {1663 if (vault.isDecoyMode()) return { ok: false, error: "vault_locked" };1664 const { deviceId } = (msg.payload ?? {}) as { deviceId?: string };1665 if (typeof deviceId !== "string" || !deviceId) {1666 return { ok: false, error: "invalid_payload" };1667 }1668 1669 1670 1671 1672 let revokedByDeviceId: string;1673 try {1674 revokedByDeviceId = await getOrCreateDeviceId();1675 } catch (error) {1676 if (!(error instanceof Error) || error.message !== "identity_migration_required") throw error;1677 revokedByDeviceId = "local";1678 }1679 await revokeDeviceByIdV3(deviceId, revokedByDeviceId);1680 1681 1682 1683 const unpairRootHex = vault.getRootKeyHex();1684 if (unpairRootHex) {1685 const unpairRoot = unhexBytes(unpairRootHex);1686 try {1687 await dropPairSecret(deviceId, unpairRoot);1688 } finally {1689 unpairRoot.fill(0);1690 }1691 } else {1692 await dropPairSecret(deviceId, null);1693 }1694 1695 1696 void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);1697 return { ok: true };1698 }16991700 case "RENAME_LOCAL_DEVICE": {1701 if (vault.isDecoyMode()) return { ok: false, error: "vault_locked" };1702 const { name } = (msg.payload ?? {}) as { name?: string };1703 if (typeof name !== "string") return { ok: false, error: "invalid_payload" };1704 await setDeviceNameV3(name);1705 return { ok: true };1706 }17071708 1709 1710 1711 1712 case "START_ADOPTION_SOURCE": {1713 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1714 if (isRelayAdoptionSourceInFlight()) {1715 return { ok: false, error: "adoption_source_in_progress" };1716 }1717 try {1718 return { ok: true, data: await startRelayAdoptionSource() };1719 } catch (error) {1720 return {1721 ok: false,1722 error: error instanceof Error ? error.message : "adoption_source_failed",1723 };1724 }1725 }17261727 1728 1729 1730 1731 case "START_ADOPTION_RECEIVER": {1732 if (isRelayAdoptionSourceInFlight()) {1733 return { ok: false, error: "adoption_receiver_in_progress" };1734 }1735 const password = (msg.payload as { password?: unknown } | undefined)?.password;1736 if (typeof password !== "string" || password.length === 0) {1737 return { ok: false, error: "invalid_payload" };1738 }1739 try {1740 return { ok: true, data: await startRelayAdoptionReceiver(password) };1741 } catch (error) {1742 return {1743 ok: false,1744 error: error instanceof Error ? error.message : "adoption_receiver_failed",1745 };1746 }1747 }17481749 1750 1751 1752 case "RECEIVER_SAS_DECISION": {1753 const payload = msg.payload as { id?: unknown; sas?: unknown; ok?: unknown } | undefined;1754 if (typeof payload?.id !== "string" || typeof payload.sas !== "string" || typeof payload.ok !== "boolean") {1755 return { ok: false, error: "invalid_payload" };1756 }1757 return { ok: resolveReceiverSasConfirmation(payload.id, payload.sas, payload.ok) };1758 }17591760 case "SOURCE_SAS_DECISION": {1761 const payload = msg.payload as { id?: unknown; ok?: unknown } | undefined;1762 if (typeof payload?.id !== "string" || typeof payload?.ok !== "boolean") {1763 return { ok: false, error: "invalid_payload" };1764 }1765 return { ok: resolveSourceSasConfirmation(payload.id, payload.ok) };1766 }1767 case "LIST_PENDING_ADOPTION_HOLDERS": {1768 return { ok: true, data: await listPendingAdoptionHolders() };1769 }17701771 case "REMOVE_PENDING_ADOPTION_HOLDER": {1772 await removePendingAdoptionHolder();1773 return { ok: true };1774 }177517761777 case "CANCEL_ADOPTION_SOURCE": {1778 cancelRelayAdoptionSource();1779 return { ok: true };1780 }17811782 case "TRIGGER_SYNC": {1783 if (vault.isDecoyMode()) return { ok: true };1784 const payload = (msg.payload ?? {}) as {1785 reason?: "vault_change" | "foreground" | "manual";1786 force?: boolean;1787 };1788 const reason = payload.reason ?? "foreground";17891790 void triggerSyncIfReachable({ reason, force: payload.force === true });1791 return { ok: true };1792 }17931794 case "GET_SYNC_STATE": {1795 if (vault.isDecoyMode()) return { ok: true, data: { status: "idle" } };1796 return { ok: true, data: getSyncState() };1797 }17981799 1800 1801 case "GET_SYNC_GENERATION": {1802 return { ok: true, data: await resolveGeneration(browserGenerationIo) };1803 }18041805 case "SECURE_RESET": {1806 if (vault.isDecoyMode()) return { ok: false, error: "vault_locked" };1807 const payload = (msg.payload ?? {}) as { password?: unknown };1808 if (typeof payload.password !== "string" || payload.password.length === 0) {1809 return { ok: false, error: "Enter your master password" };1810 }1811 if (!(await vault.verifyMainPassword(payload.password))) {1812 return { ok: false, error: "Incorrect master password" };1813 }1814 1815 1816 1817 await enterResetRequired(browserGenerationIo);1818 const result = await confirmSecureReset(browserGenerationIo, {1819 nowMs: Date.now(),1820 });1821 return result === "V2_ANCHOR"1822 ? { ok: true, data: await resolveGeneration(browserGenerationIo) }1823 : {1824 ok: false,1825 error: "The reset did not complete — nothing was lost, try again",1826 };1827 }18281829 case "GET_SYNC_SETTINGS": {1830 if (vault.isDecoyMode()) return { ok: true, data: { mode: "off", relayUrl: "" } };1831 return { ok: true, data: await getSyncSettings() };1832 }18331834 case "SET_SYNC_SETTINGS": {1835 if (vault.isDecoyMode()) return { ok: false, error: "vault_locked" };1836 const payload = (msg.payload ?? {}) as {1837 mode?: unknown;1838 relayUrl?: unknown;1839 };1840 try {1841 1842 1843 if (payload.relayUrl !== undefined) {1844 if (typeof payload.relayUrl !== "string") {1845 return { ok: false, error: "invalid_relay_url" };1846 }1847 return { ok: true, data: await setRelayUrl(payload.relayUrl) };1848 }1849 if (payload.mode !== undefined) {1850 if (payload.mode !== "public" && payload.mode !== "private" && payload.mode !== "off") {1851 return { ok: false, error: "invalid_sync_mode" };1852 }1853 return { ok: true, data: await setSyncMode(payload.mode as SyncMode) };1854 }1855 return { ok: true, data: await getSyncSettings() };1856 } catch (error) {1857 return {1858 ok: false,1859 error: error instanceof Error ? error.message : "invalid_relay_url",1860 };1861 }1862 }186318641865 case "GET_SYNC_ALERTS": {1866 if (vault.isDecoyMode()) return { ok: true, data: [] };1867 const { consume } = (msg.payload ?? {}) as { consume?: boolean };1868 const alerts = await getPendingSyncAlerts();1869 1870 1871 1872 if (consume) await clearPendingSyncAlerts();1873 return { ok: true, data: alerts };1874 }18751876 case "GET_PENDING_VOUCHED_DEVICES": {1877 if (vault.isDecoyMode()) return { ok: true, data: [] };1878 return { ok: true, data: await listPendingVouchedDevices() };1879 }1880 case "CONFIRM_VOUCHED_DEVICE": {1881 if (vault.isDecoyMode()) return { ok: false, error: "vault_locked" };1882 const { deviceId } = msg.payload as { deviceId: string };1883 const pending = await getPendingVouchedDevice(deviceId);1884 if (!pending) return { ok: false, error: "not_pending" };1885 1886 1887 1888 if (await isDeviceRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {1889 await removePendingVouchedDevice(deviceId);1890 return { ok: false, error: "device_revoked" };1891 }1892 await upsertPairedDeviceV3({1893 deviceId: pending.deviceId,1894 deviceName: pending.deviceName,1895 deviceType:1896 pending.deviceType as import("../shared/sync-types").PairedDevice["deviceType"],1897 longTermPublicKeyHex: pending.longTermPublicKeyHex,1898 signingPublicKeyHex: pending.signingPublicKeyHex,1899 wsPort: pending.wsPort,1900 pairedAt: pending.pairedAt,1901 trustSource: "vouched",1902 });1903 await removePendingVouchedDevice(deviceId);1904 1905 1906 void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);1907 return { ok: true };1908 }1909 case "REJECT_VOUCHED_DEVICE": {1910 if (vault.isDecoyMode()) return { ok: true };1911 const { deviceId } = msg.payload as { deviceId: string };1912 await removePendingVouchedDevice(deviceId);1913 return { ok: true };1914 }19151916 case "ROTATE_SYNC_IDENTITY": {1917 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1918 const result = await vault.rotateSyncIdentity();1919 return result.ok ? { ok: true } : { ok: false, error: result.error };1920 }19211922 default:1923 return { ok: false, error: "unknown_message_type" };1924 }1925 } catch (err) {1926 return {1927 ok: false,1928 error: err instanceof Error ? err.message : "operation_failed",1929 };1930 }1931}19321933193419351936function buildIdentitySuggestions(1937 entries: VaultEntry[],1938): Array<{ email: string; username: string; count: number }> {1939 const usernameCounts = new Map<string, number>();1940 const usernameEmail = new Map<string, string>();1941 for (const entry of entries) {1942 const username = entry.username ?? "";1943 const email = (entry as VaultEntry & { email?: string }).email ?? "";1944 if (!username && !email) continue;1945 const key = username || email;1946 usernameCounts.set(key, (usernameCounts.get(key) ?? 0) + 1);1947 if (!usernameEmail.has(key)) usernameEmail.set(key, email);1948 }1949 return Array.from(usernameCounts.entries())1950 .sort((a, b) => b[1] - a[1])1951 .map(([username, count]) => ({1952 email: usernameEmail.get(username) ?? "",1953 username,1954 count,1955 }));1956}195719581959function broadcastToContentScripts(message: { type: string }): void {1960 if (!chrome.tabs?.query) return;1961 chrome.tabs.query({}, (tabs) => {1962 for (const tab of tabs) {1963 if (tab.id !== undefined) {1964 chrome.tabs.sendMessage(tab.id, message).catch(() => {1965 1966 });1967 }1968 }1969 });1970}19711972function shouldRefreshAutoLock(1973 type: MessageType,1974 sender: Partial<chrome.runtime.MessageSender>,1975): boolean {1976 if (!isExtensionPageSender(sender)) return false;1977 return (1978 type !== "HAS_VAULT" &&1979 type !== "IS_UNLOCKED" &&1980 type !== "GET_VAULT_STATUS" &&1981 type !== "LOCK" &&1982 type !== "UPDATE_SETTINGS" &&1983 type !== "GET_PENDING_PASSKEY" &&1984 type !== "GET_PASSKEY_PROVIDER_STATE" &&1985 type !== "DECLINE_CARD_SAVE" &&1986 type !== "IS_CARD_DECLINED" &&1987 type !== "AUTOFILL_QUERY" &&1988 type !== "ML_PREDICT_BATCH"1989 );1990}19911992function isAllowedCanaryWebhookUrl(value: string): boolean {1993 try {1994 const url = new URL(value);1995 if (url.protocol !== "https:") return false;19961997 const hostname = url.hostname.toLowerCase();1998 if (1999 hostname === "localhost" ||2000 hostname.endsWith(".localhost") ||2001 isPrivateIpv4(hostname) ||2002 isPrivateIpv6(hostname)2003 ) {2004 return false;2005 }20062007 return true;2008 } catch {2009 return false;2010 }2011}20122013function isPrivateIpv4(hostname: string): boolean {2014 if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {2015 return true;2016 }20172018 const octets = hostname.split(".").map((part) => Number(part));2019 if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {2020 return false;2021 }20222023 return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;2024}20252026function isPrivateIpv6(hostname: string): boolean {2027 const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();2028 2029 2030 2031 2032 2033 2034 const mapped = /^::ffff:(.+)$/.exec(normalized);2035 if (mapped) {2036 const tail = mapped[1] ?? "";2037 if (tail.includes(".")) return isPrivateIpv4(tail);2038 const parts = tail.split(":");2039 const [hi, lo] = parts;2040 if (2041 parts.length !== 2 ||2042 !hi ||2043 !lo ||2044 !/^[0-9a-f]{1,4}$/.test(hi) ||2045 !/^[0-9a-f]{1,4}$/.test(lo)2046 ) {2047 return true;2048 }2049 const octets = [2050 (Number.parseInt(hi, 16) >>> 8) & 0xff,2051 Number.parseInt(hi, 16) & 0xff,2052 (Number.parseInt(lo, 16) >>> 8) & 0xff,2053 Number.parseInt(lo, 16) & 0xff,2054 ];2055 return isPrivateIpv4(octets.join("."));2056 }2057 return (2058 normalized === "::1" ||2059 normalized.startsWith("fc") ||2060 normalized.startsWith("fd") ||2061 normalized.startsWith("fe80:")2062 );2063}