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