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 const payload = msg.payload as CodexRestorePayload;1258 return await handleRestoreFromCodex(payload);1259 }1260 case "OPEN_PRO_GATE": {1261 1262 const { feature } = (msg.payload ?? {}) as { feature?: string };1263 if (chrome.storage.session) {1264 await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });1265 }1266 try { await chrome.action.openPopup(); } catch { }1267 return { ok: true };1268 }1269 case "OPEN_POPUP": {1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 const { entryId } = (msg.payload ?? {}) as { entryId?: unknown };1280 if (entryId !== undefined && (typeof entryId !== "string" || entryId.length === 0 || entryId.length > 64)) {1281 return { ok: false, error: "invalid_payload" };1282 }1283 if (chrome.storage.session) {1284 if (typeof entryId === "string") {1285 await chrome.storage.session.set({ peach_open_entry: entryId });1286 } else {1287 await chrome.storage.session.set({ peach_inline_unlock: true });1288 }1289 }1290 try { await chrome.action.openPopup(); } catch { }1291 return { ok: true };1292 }1293 case "OPEN_DETACHED_POPUP": {1294 1295 1296 1297 1298 1299 1300 const { route } = (msg.payload ?? {}) as { route?: string };1301 const search = route === "peachscript"1302 ? "?restore=peachscript"1303 : route === "csv"1304 ? "?restore=csv"1305 : route === "codex-pdf"1306 ? "?codex=pdf"1307 : null;1308 if (!search) return { ok: false, error: "bad_route" };1309 try {1310 const popupHtml =1311 chrome.runtime.getManifest().action?.default_popup ?? "src/popup/index.html";1312 await chrome.windows.create({1313 url: chrome.runtime.getURL(`${popupHtml}${search}`),1314 type: "popup",1315 width: 320,1316 height: 550,1317 });1318 return { ok: true, data: { detached: true } };1319 } catch {1320 return { ok: false };1321 }1322 }1323 case "GET_IDENTITIES": {1324 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1325 return { ok: true, data: vault.getIdentities() };1326 }1327 case "TOUCH_AUTO_LOCK": {1328 1329 1330 return { ok: true };1331 }1332 case "GET_ADVANCED_HEALTH_REPORT": {1333 const gate = await requirePro("advanced_health");1334 if (!gate.allowed) return { ok: false, error: "pro_required" };1335 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1336 const report = await buildAdvancedHealthReport(vault.getEntries());1337 return { ok: true, data: report };1338 }1339 case "IS_DURESS_ENABLED": {1340 const enabled = await vault.isDuressEnabled();1341 return { ok: true, data: enabled };1342 }1343 case "SETUP_DURESS": {1344 1345 1346 1347 1348 const gate = await requirePro("duress_vault");1349 if (!gate.allowed) return { ok: false, error: "pro_required" };1350 1351 1352 1353 if (!vault.isUnlocked()) return { ok: false, error: "session_expired" };1354 const { duressPassword } = msg.payload as { duressPassword: string };1355 await vault.setupDuress(duressPassword);1356 return { ok: true };1357 }1358 case "DISABLE_DURESS": {1359 await vault.disableDuress();1360 return { ok: true };1361 }1362 case "VERIFY_MAIN_PASSWORD": {1363 const { password } = msg.payload as { password: string };1364 const ok = await vault.verifyMainPassword(password);1365 return { ok: true, data: ok };1366 }1367 case "CHANGE_PASSWORD": {1368 const { currentPassword, newPassword } = msg.payload as {1369 currentPassword: string;1370 newPassword: string;1371 };1372 const changed = await vault.changePassword(currentPassword, newPassword);1373 return changed1374 ? { ok: true }1375 : { ok: false, error: "incorrect_current_password" };1376 }1377 case "GET_SITE_PASSWORD_POLICY": {1378 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });1379 if (!tab?.id) return { ok: true, data: null };1380 try {1381 const response = await chrome.tabs.sendMessage(tab.id, {1382 type: "GET_SITE_PASSWORD_POLICY",1383 });1384 return (response as MessageResponse) ?? { ok: true, data: null };1385 } catch {1386 return { ok: true, data: null };1387 }1388 }1389 case "RECORD_MANUAL_SUMMON": {1390 const { domain } = (msg.payload ?? {}) as { domain?: unknown };1391 const origin = attestedSenderOrigin(sender);1392 let senderDomain: string | null = null;1393 if (origin) {1394 try {1395 const hostname = new URL(origin).hostname;1396 senderDomain = extractDomain(hostname) ?? hostname;1397 } catch {1398 senderDomain = null;1399 }1400 }1401 if (typeof domain !== "string" || domain.length > 253 || domain !== senderDomain) {1402 return { ok: false, error: "invalid_domain" };1403 }1404 const settings = vault.getSettings();1405 const existing = settings.manualSummonDomains?.[domain];1406 const now = Date.now();1407 await vault.updateSettings({1408 manualSummonDomains: {1409 ...settings.manualSummonDomains,1410 [domain]: {1411 firstSummonedAt: existing?.firstSummonedAt ?? now,1412 summonCount: (existing?.summonCount ?? 0) + 1,1413 lastSummonedAt: now,1414 },1415 },1416 });1417 return { ok: true };1418 }1419 case "DECLINE_CARD_SAVE": {1420 1421 1422 1423 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1424 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1425 await vault.declineCardSaveOffer(last4, expMonth, expYear);1426 return { ok: true };1427 }1428 case "IS_CARD_DECLINED": {1429 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1430 const declined = await vault.isCardDeclined(last4, expMonth, expYear);1431 return { ok: true, data: declined };1432 }1433 case "INITIATE_FIREFOX_PASSKEY_CREATE": {1434 const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {1435 rpId?: string;1436 rpName?: string;1437 userName?: string;1438 userDisplayName?: string;1439 };1440 if (!rpId || !userName) return { ok: false, error: "rpid_and_username_required" };1441 return await initiateFirefoxPasskeyCreate({1442 rpId,1443 rpName: rpName ?? rpId,1444 userName,1445 userDisplayName,1446 }, attestedSenderOrigin(sender));1447 }14481449 case "COMPLETE_FIREFOX_PASSKEY_CREATE": {1450 const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;1451 if (!payload) return { ok: false, error: "missing_payload" };1452 return await completeFirefoxPasskeyCreate(payload);1453 }14541455 case "PASSKEY_OFFER_DECLINE": {1456 const { domain } = (msg.payload ?? {}) as { domain?: string };1457 const origin = attestedSenderOrigin(sender);1458 if (!domain || !origin || !isValidRpId(domain, origin)) return { ok: false, error: "invalid_domain" };1459 await recordPasskeyOfferDecline(domain);1460 return { ok: true };1461 }14621463 case "GET_PASSKEY_OFFER_DECLINED": {1464 const { domain } = (msg.payload ?? {}) as { domain?: string };1465 const origin = attestedSenderOrigin(sender);1466 if (!domain || !origin || !isValidRpId(domain, origin)) return { ok: false, error: "invalid_domain" };1467 return { ok: true, data: await isPasskeyOfferDeclined(domain) };1468 }14691470 case "PASSKEY_HAS_CREDENTIALS": {1471 const payload = msg.payload as {1472 rpId?: string;1473 allowCredentials?: Array<{ id: string; type?: string }>;1474 } | undefined;1475 if (!payload?.rpId) return { ok: false, error: "rpid_required" };1476 return await handlePasskeyHasCredentials({1477 rpId: payload.rpId,1478 allowCredentials: payload.allowCredentials ?? [],1479 }, attestedSenderOrigin(sender));1480 }14811482 case "PASSKEY_CREATE": {1483 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;1484 if (!payload?.rpId || !payload?.userName) {1485 return { ok: false, error: "rpid_and_username_required" };1486 }1487 return await handleFirefoxPasskeyCreate(payload, attestedSenderOrigin(sender));1488 }14891490 case "PASSKEY_GET": {1491 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;1492 if (!payload?.rpId) return { ok: false, error: "rpid_required" };1493 return await handleFirefoxPasskeyGet(payload, attestedSenderOrigin(sender));1494 }14951496 case "PASSKEY_AUTH_RESPONSE": {1497 const { requestId, approved } = (msg.payload ?? {}) as {1498 requestId?: string | number;1499 approved?: boolean;1500 };1501 if (requestId === undefined || requestId === null || typeof approved !== 'boolean') {1502 return { ok: false, error: 'requestId and approved required' };1503 }1504 const pending = await getPendingPasskeyRequest();1505 const senderOrigin = attestedSenderOrigin(sender);1506 if (1507 (pending && pending.requestId !== Number(requestId)) ||1508 (pending?.tabId !== undefined && sender.tab?.id !== pending.tabId) ||1509 (pending?.origin !== undefined && senderOrigin !== pending.origin)1510 ) {1511 return { ok: false, error: "invalid_passkey_approval_sender" };1512 }1513 await resolvePendingPasskeyRequest(Number(requestId), approved);1514 return { ok: true };1515 }15161517 case "GET_PASSKEY_FOR_DOMAIN": {1518 const { domain } = (msg.payload ?? {}) as { domain?: string };1519 if (!domain) return { ok: false, error: "domain_required" };1520 1521 1522 1523 const origin = attestedSenderOrigin(sender);1524 if (!origin || !isValidRpId(domain, origin)) return { ok: true, data: null };1525 if (!vault.isUnlocked()) return { ok: true, data: null };1526 const match = vault1527 .getEntries()1528 .find((entry) => entry.passkey?.rpId === domain);1529 if (!match || !match.passkey) return { ok: true, data: null };1530 1531 1532 1533 1534 1535 let bannerShown = false;1536 if (chrome.storage.session) {1537 const flag = await chrome.storage.session.get(`peach_state11_shown:${domain}`);1538 bannerShown = Boolean((flag as Record<string, unknown>)[`peach_state11_shown:${domain}`]);1539 }1540 return {1541 ok: true,1542 data: {1543 rpId: match.passkey.rpId,1544 userName: match.passkey.userName,1545 browser: match.passkey.browser,1546 bannerShown,1547 },1548 };1549 }15501551 case "MARK_PASSKEY_BANNER_SHOWN": {1552 const { domain } = (msg.payload ?? {}) as { domain?: string };1553 if (typeof domain !== "string" || !domain) return { ok: false, error: "domain_required" };1554 1555 1556 const origin = attestedSenderOrigin(sender);1557 if (!origin || !isValidRpId(domain, origin)) return { ok: true };1558 if (chrome.storage.session) {1559 await chrome.storage.session.set({ [`peach_state11_shown:${domain}`]: Date.now() });1560 }1561 return { ok: true };1562 }15631564 case "DESTROY_VAULT": {1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 const preserved = await chrome.storage.local.get([1576 "peach_license",1577 "peach_pro_celebrated",1578 ]);1579 await vault.lock();1580 await storageClear();1581 await vault.clearVaultHistory();1582 const toRestore: Record<string, unknown> = {};1583 if (preserved.peach_license !== undefined) toRestore.peach_license = preserved.peach_license;1584 if (preserved.peach_pro_celebrated !== undefined) toRestore.peach_pro_celebrated = preserved.peach_pro_celebrated;1585 if (Object.keys(toRestore).length > 0) {1586 await chrome.storage.local.set(toRestore);1587 }1588 if (chrome.storage.session) {1589 await chrome.storage.session.clear();1590 }1591 await clearRecoveryConfirmPending();1592 return { ok: true };1593 }15941595 case "GET_IDENTITY_SUGGESTIONS": {1596 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1597 const entries = vault.getEntries();1598 const suggestions = buildIdentitySuggestions(entries);1599 return { ok: true, data: { suggestions } };1600 }1601 case "ML_INIT":1602 return { ok: true };16031604 case "ML_PREDICT_BATCH": {1605 const { featuresArray } = (msg.payload ?? {}) as { featuresArray?: unknown };1606 if (!isValidMLFeatureBatch(featuresArray, MAX_ML_BATCH_FIELDS)) {1607 return { ok: false, error: "invalid_payload" };1608 }1609 if (!claimMLWork(sender, featuresArray.length)) {1610 return { ok: false, error: "rate_limited" };1611 }1612 return { ok: true, data: mlPredictBatch(featuresArray) };1613 }16141615 1616 1617 1618 1619 case "GET_LOCAL_IDENTITY": {1620 1621 1622 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1623 1624 1625 const identity: LocalDeviceIdentity & { license?: string } = await buildLocalIdentity();1626 1627 1628 if (await isProUser()) {1629 const license = await getLicenseKey();1630 if (license) identity.license = license;1631 }1632 return { ok: true, data: identity };1633 }16341635 case "LIST_PAIRED_DEVICES": {1636 if (vault.isDecoyMode()) return { ok: true, data: { devices: [] } };1637 try {1638 const devices = await listPairedDevicesV3();1639 return { ok: true, data: { devices } };1640 } catch (err) {1641 if (err instanceof TrustDataCorruptedError) {1642 return { ok: false, error: "trust_data_corrupted" };1643 }1644 throw err;1645 }1646 }16471648 case "RENAME_PAIRED_DEVICE": {1649 if (vault.isDecoyMode()) return { ok: false, error: "vault_locked" };1650 const { deviceId, name } = (msg.payload ?? {}) as { deviceId?: string; name?: string };1651 if (typeof deviceId !== "string" || !deviceId || typeof name !== "string") {1652 return { ok: false, error: "invalid_payload" };1653 }1654 await renamePairedDeviceV3(deviceId, name);1655 return { ok: true };1656 }16571658 case "UNPAIR_DEVICE": {1659 if (vault.isDecoyMode()) return { ok: false, error: "vault_locked" };1660 const { deviceId } = (msg.payload ?? {}) as { deviceId?: string };1661 if (typeof deviceId !== "string" || !deviceId) {1662 return { ok: false, error: "invalid_payload" };1663 }1664 1665 1666 1667 await revokeDeviceByIdV3(deviceId, await getOrCreateDeviceId());1668 1669 1670 1671 const unpairRootHex = vault.getRootKeyHex();1672 if (unpairRootHex) {1673 const unpairRoot = unhexBytes(unpairRootHex);1674 try {1675 await dropPairSecret(deviceId, unpairRoot);1676 } finally {1677 unpairRoot.fill(0);1678 }1679 } else {1680 await dropPairSecret(deviceId, null);1681 }1682 1683 1684 void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);1685 return { ok: true };1686 }16871688 case "RENAME_LOCAL_DEVICE": {1689 if (vault.isDecoyMode()) return { ok: false, error: "vault_locked" };1690 const { name } = (msg.payload ?? {}) as { name?: string };1691 if (typeof name !== "string") return { ok: false, error: "invalid_payload" };1692 await setDeviceNameV3(name);1693 return { ok: true };1694 }16951696 1697 1698 1699 1700 case "START_ADOPTION_SOURCE": {1701 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1702 if (isRelayAdoptionSourceInFlight()) {1703 return { ok: false, error: "adoption_source_in_progress" };1704 }1705 try {1706 return { ok: true, data: await startRelayAdoptionSource() };1707 } catch (error) {1708 return {1709 ok: false,1710 error: error instanceof Error ? error.message : "adoption_source_failed",1711 };1712 }1713 }17141715 1716 1717 1718 1719 case "START_ADOPTION_RECEIVER": {1720 if (isRelayAdoptionSourceInFlight()) {1721 return { ok: false, error: "adoption_receiver_in_progress" };1722 }1723 const password = (msg.payload as { password?: unknown } | undefined)?.password;1724 if (typeof password !== "string" || password.length === 0) {1725 return { ok: false, error: "invalid_payload" };1726 }1727 try {1728 return { ok: true, data: await startRelayAdoptionReceiver(password) };1729 } catch (error) {1730 return {1731 ok: false,1732 error: error instanceof Error ? error.message : "adoption_receiver_failed",1733 };1734 }1735 }17361737 1738 1739 1740 case "RECEIVER_SAS_DECISION": {1741 const payload = msg.payload as { id?: unknown; sas?: unknown; ok?: unknown } | undefined;1742 if (typeof payload?.id !== "string" || typeof payload.sas !== "string" || typeof payload.ok !== "boolean") {1743 return { ok: false, error: "invalid_payload" };1744 }1745 return { ok: resolveReceiverSasConfirmation(payload.id, payload.sas, payload.ok) };1746 }17471748 case "SOURCE_SAS_DECISION": {1749 const payload = msg.payload as { id?: unknown; ok?: unknown } | undefined;1750 if (typeof payload?.id !== "string" || typeof payload?.ok !== "boolean") {1751 return { ok: false, error: "invalid_payload" };1752 }1753 return { ok: resolveSourceSasConfirmation(payload.id, payload.ok) };1754 }1755 case "LIST_PENDING_ADOPTION_HOLDERS": {1756 return { ok: true, data: await listPendingAdoptionHolders() };1757 }17581759 case "REMOVE_PENDING_ADOPTION_HOLDER": {1760 await removePendingAdoptionHolder();1761 return { ok: true };1762 }176317641765 case "CANCEL_ADOPTION_SOURCE": {1766 cancelRelayAdoptionSource();1767 return { ok: true };1768 }17691770 case "TRIGGER_SYNC": {1771 if (vault.isDecoyMode()) return { ok: true };1772 const payload = (msg.payload ?? {}) as {1773 reason?: "vault_change" | "foreground" | "manual";1774 force?: boolean;1775 };1776 const reason = payload.reason ?? "foreground";17771778 void triggerSyncIfReachable({ reason, force: payload.force === true });1779 return { ok: true };1780 }17811782 case "GET_SYNC_STATE": {1783 if (vault.isDecoyMode()) return { ok: true, data: { status: "idle" } };1784 return { ok: true, data: getSyncState() };1785 }17861787 1788 1789 case "GET_SYNC_GENERATION": {1790 return { ok: true, data: await resolveGeneration(browserGenerationIo) };1791 }17921793 case "SECURE_RESET": {1794 if (vault.isDecoyMode()) return { ok: false, error: "vault_locked" };1795 const payload = (msg.payload ?? {}) as { password?: unknown };1796 if (typeof payload.password !== "string" || payload.password.length === 0) {1797 return { ok: false, error: "Enter your master password" };1798 }1799 if (!(await vault.verifyMainPassword(payload.password))) {1800 return { ok: false, error: "Incorrect master password" };1801 }1802 1803 1804 1805 await enterResetRequired(browserGenerationIo);1806 const result = await confirmSecureReset(browserGenerationIo, {1807 nowMs: Date.now(),1808 });1809 return result === "V2_ANCHOR"1810 ? { ok: true, data: await resolveGeneration(browserGenerationIo) }1811 : {1812 ok: false,1813 error: "The reset did not complete — nothing was lost, try again",1814 };1815 }18161817 case "GET_SYNC_SETTINGS": {1818 if (vault.isDecoyMode()) return { ok: true, data: { mode: "off", relayUrl: "" } };1819 return { ok: true, data: await getSyncSettings() };1820 }18211822 case "SET_SYNC_SETTINGS": {1823 if (vault.isDecoyMode()) return { ok: false, error: "vault_locked" };1824 const payload = (msg.payload ?? {}) as {1825 mode?: unknown;1826 relayUrl?: unknown;1827 };1828 try {1829 1830 1831 if (payload.relayUrl !== undefined) {1832 if (typeof payload.relayUrl !== "string") {1833 return { ok: false, error: "invalid_relay_url" };1834 }1835 return { ok: true, data: await setRelayUrl(payload.relayUrl) };1836 }1837 if (payload.mode !== undefined) {1838 if (payload.mode !== "public" && payload.mode !== "private" && payload.mode !== "off") {1839 return { ok: false, error: "invalid_sync_mode" };1840 }1841 return { ok: true, data: await setSyncMode(payload.mode as SyncMode) };1842 }1843 return { ok: true, data: await getSyncSettings() };1844 } catch (error) {1845 return {1846 ok: false,1847 error: error instanceof Error ? error.message : "invalid_relay_url",1848 };1849 }1850 }185118521853 case "GET_SYNC_ALERTS": {1854 if (vault.isDecoyMode()) return { ok: true, data: [] };1855 const { consume } = (msg.payload ?? {}) as { consume?: boolean };1856 const alerts = await getPendingSyncAlerts();1857 1858 1859 1860 if (consume) await clearPendingSyncAlerts();1861 return { ok: true, data: alerts };1862 }18631864 case "GET_PENDING_VOUCHED_DEVICES": {1865 if (vault.isDecoyMode()) return { ok: true, data: [] };1866 return { ok: true, data: await listPendingVouchedDevices() };1867 }1868 case "CONFIRM_VOUCHED_DEVICE": {1869 if (vault.isDecoyMode()) return { ok: false, error: "vault_locked" };1870 const { deviceId } = msg.payload as { deviceId: string };1871 const pending = await getPendingVouchedDevice(deviceId);1872 if (!pending) return { ok: false, error: "not_pending" };1873 1874 1875 1876 if (await isDeviceRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {1877 await removePendingVouchedDevice(deviceId);1878 return { ok: false, error: "device_revoked" };1879 }1880 await upsertPairedDeviceV3({1881 deviceId: pending.deviceId,1882 deviceName: pending.deviceName,1883 deviceType:1884 pending.deviceType as import("../shared/sync-types").PairedDevice["deviceType"],1885 longTermPublicKeyHex: pending.longTermPublicKeyHex,1886 signingPublicKeyHex: pending.signingPublicKeyHex,1887 wsPort: pending.wsPort,1888 pairedAt: pending.pairedAt,1889 trustSource: "vouched",1890 });1891 await removePendingVouchedDevice(deviceId);1892 1893 1894 void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);1895 return { ok: true };1896 }1897 case "REJECT_VOUCHED_DEVICE": {1898 if (vault.isDecoyMode()) return { ok: true };1899 const { deviceId } = msg.payload as { deviceId: string };1900 await removePendingVouchedDevice(deviceId);1901 return { ok: true };1902 }19031904 case "ROTATE_SYNC_IDENTITY": {1905 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1906 const result = await vault.rotateSyncIdentity();1907 return result.ok ? { ok: true } : { ok: false, error: result.error };1908 }19091910 default:1911 return { ok: false, error: "unknown_message_type" };1912 }1913 } catch (err) {1914 return {1915 ok: false,1916 error: err instanceof Error ? err.message : "operation_failed",1917 };1918 }1919}19201921192219231924function buildIdentitySuggestions(1925 entries: VaultEntry[],1926): Array<{ email: string; username: string; count: number }> {1927 const usernameCounts = new Map<string, number>();1928 const usernameEmail = new Map<string, string>();1929 for (const entry of entries) {1930 const username = entry.username ?? "";1931 const email = (entry as VaultEntry & { email?: string }).email ?? "";1932 if (!username && !email) continue;1933 const key = username || email;1934 usernameCounts.set(key, (usernameCounts.get(key) ?? 0) + 1);1935 if (!usernameEmail.has(key)) usernameEmail.set(key, email);1936 }1937 return Array.from(usernameCounts.entries())1938 .sort((a, b) => b[1] - a[1])1939 .map(([username, count]) => ({1940 email: usernameEmail.get(username) ?? "",1941 username,1942 count,1943 }));1944}194519461947function broadcastToContentScripts(message: { type: string }): void {1948 if (!chrome.tabs?.query) return;1949 chrome.tabs.query({}, (tabs) => {1950 for (const tab of tabs) {1951 if (tab.id !== undefined) {1952 chrome.tabs.sendMessage(tab.id, message).catch(() => {1953 1954 });1955 }1956 }1957 });1958}19591960function shouldRefreshAutoLock(1961 type: MessageType,1962 sender: Partial<chrome.runtime.MessageSender>,1963): boolean {1964 if (!isExtensionPageSender(sender)) return false;1965 return (1966 type !== "HAS_VAULT" &&1967 type !== "IS_UNLOCKED" &&1968 type !== "GET_VAULT_STATUS" &&1969 type !== "LOCK" &&1970 type !== "UPDATE_SETTINGS" &&1971 type !== "GET_PENDING_PASSKEY" &&1972 type !== "GET_PASSKEY_PROVIDER_STATE" &&1973 type !== "DECLINE_CARD_SAVE" &&1974 type !== "IS_CARD_DECLINED" &&1975 type !== "AUTOFILL_QUERY" &&1976 type !== "ML_PREDICT_BATCH"1977 );1978}19791980function isAllowedCanaryWebhookUrl(value: string): boolean {1981 try {1982 const url = new URL(value);1983 if (url.protocol !== "https:") return false;19841985 const hostname = url.hostname.toLowerCase();1986 if (1987 hostname === "localhost" ||1988 hostname.endsWith(".localhost") ||1989 isPrivateIpv4(hostname) ||1990 isPrivateIpv6(hostname)1991 ) {1992 return false;1993 }19941995 return true;1996 } catch {1997 return false;1998 }1999}20002001function isPrivateIpv4(hostname: string): boolean {2002 if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {2003 return true;2004 }20052006 const octets = hostname.split(".").map((part) => Number(part));2007 if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {2008 return false;2009 }20102011 return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;2012}20132014function isPrivateIpv6(hostname: string): boolean {2015 const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();2016 2017 2018 2019 2020 2021 2022 const mapped = /^::ffff:(.+)$/.exec(normalized);2023 if (mapped) {2024 const tail = mapped[1] ?? "";2025 if (tail.includes(".")) return isPrivateIpv4(tail);2026 const parts = tail.split(":");2027 const [hi, lo] = parts;2028 if (2029 parts.length !== 2 ||2030 !hi ||2031 !lo ||2032 !/^[0-9a-f]{1,4}$/.test(hi) ||2033 !/^[0-9a-f]{1,4}$/.test(lo)2034 ) {2035 return true;2036 }2037 const octets = [2038 (Number.parseInt(hi, 16) >>> 8) & 0xff,2039 Number.parseInt(hi, 16) & 0xff,2040 (Number.parseInt(lo, 16) >>> 8) & 0xff,2041 Number.parseInt(lo, 16) & 0xff,2042 ];2043 return isPrivateIpv4(octets.join("."));2044 }2045 return (2046 normalized === "::1" ||2047 normalized.startsWith("fc") ||2048 normalized.startsWith("fd") ||2049 normalized.startsWith("fe80:")2050 );2051}