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