123456789101112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import {15 rootKeyToWords,16 wordsToRootKeyMaterial,17 deriveRecoveryKey,18} from "../shared/recovery-words";19import type {20 AccentColorName,21 LegacyVaultEntry,22 ManualSummonRecord,23 SavedIdentity,24 StoredVault,25 VaultEntry,26 VaultHistory,27 VaultSnapshot,28} from "../shared/types";29import { decrypt, encrypt, zeroKey } from "./aes";30import { clearAutoLock, touchAutoLock } from "./alarms";3132type EntryDraft = Omit<33 VaultEntry,34 "id" | "createdAt" | "modifiedAt" | "deletedAt"35>;363738394041const SLOT_A = "peach_vault_a";42const SLOT_B = "peach_vault_b";434445const LEGACY_MAIN = "peach_vault";46const LEGACY_DECOY = "peach_decoy_vault";47const LEGACY_DURESS_FLAG = "peach_duress";48495051const PAD_BLOCK = 1024;5253let derivedKey: Uint8Array | null = null;5455565758let rootKey: Uint8Array | null = null;59let entries: VaultEntry[] = [];60let domainIndex: Map<string, VaultEntry[]> = new Map();61let vaultMode: "real" | "decoy" = "real";62636465let duressConfigured = false;6667let migrationRan = false;686970717273export type DisplayFont = "cormorant-italic" | "cormorant" | "inter" | "system";74export type TextSize = "small" | "medium" | "large";7576export interface VaultSettings {77 accentColor?: AccentColorName;78 theme?: "dark" | "light";79 autoLockMinutes?: number;80 clipboardClearSeconds?: number;81 lockOnClose?: boolean;82 breachMonitoring?: boolean;83 passkeyProviderEnabled?: boolean;84 trashRetentionDays?: number;85 manualSummonDomains?: Record<string, ManualSummonRecord>;86 declinedCardFingerprints?: string[];87 8889909192 displayFont?: DisplayFont;93 9495969798 textSize?: TextSize;99 100101102103104 reduceMotion?: boolean;105 106107108109110 biometricsEnabled?: boolean;111 112113114115116117118 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;119}120121let settings: VaultSettings = {};122123export function getSettings(): VaultSettings {124 return settings;125}126127export async function ensureSettingsLoaded(): Promise<VaultSettings> {128 await loadSettings();129 return settings;130}131132export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {133 await loadSettings();134 settings = { ...settings, ...patch };135 await persistSettings();136}137138async function persistSettings(): Promise<void> {139 try {140 await chrome.storage.local.set({ peach_settings: settings });141 } catch (err) {142 throw mapStorageError(err);143 }144}145146function mapStorageError(err: unknown): Error {147 const message = err instanceof Error ? err.message : String(err);148 if (/QUOTA_BYTES|quota/i.test(message)) {149 return new Error("STORAGE_QUOTA_EXCEEDED");150 }151 return err instanceof Error ? err : new Error(message);152}153154async function loadSettings(): Promise<void> {155 const result = await chrome.storage.local.get("peach_settings");156 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};157}158159160161162163export async function checkBreach(password: string): Promise<number> {164 const encoder = new TextEncoder();165 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));166 const hashHex = Array.from(new Uint8Array(hashBuffer))167 .map((b) => b.toString(16).padStart(2, "0"))168 .join("")169 .toUpperCase();170171 const prefix = hashHex.slice(0, 5);172 const suffix = hashHex.slice(5);173174 try {175 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);176 if (!res.ok) return 0;177 const text = await res.text();178 const match = text.split("\r\n").find((l) => l.startsWith(suffix));179 return match ? parseInt(match.split(":")[1], 10) : 0;180 } catch {181 return 0; 182 }183}184185export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {186 const fingerprint = await cardFingerprint(last4, expMonth, expYear);187 await loadSettings();188 settings = {189 ...settings,190 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],191 };192 await persistSettings();193}194195export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {196 await loadSettings();197 const fingerprint = await cardFingerprint(last4, expMonth, expYear);198 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);199}200201async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {202 const encoder = new TextEncoder();203 const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));204 return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");205}206207function hex(buf: Uint8Array): string {208 return Array.from(buf)209 .map((b) => b.toString(16).padStart(2, "0"))210 .join("");211}212213function unhex(s: string): Uint8Array {214 const out = new Uint8Array(s.length / 2);215 for (let i = 0; i < s.length; i += 2) {216 out[i / 2] = parseInt(s.slice(i, i + 2), 16);217 }218 return out;219}220221222223224225226227228229230231232async function generateAndWrapRootKey(): Promise<{233 rawRootKey: string; 234 recoverySalt: string;235 wrappedRootKey: string;236 wrappedNonce: string;237 wrappedTag: string;238}> {239 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));240241 242 const words = await rootKeyToWords(rootKeyBytes);243 const wordMaterial = await wordsToRootKeyMaterial(words);244 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));245 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);246247 248 249 const nonce = crypto.getRandomValues(new Uint8Array(12));250 const encoded = new TextEncoder().encode(hex(rootKeyBytes));251 const raw = await crypto.subtle.encrypt(252 {253 name: "AES-GCM",254 iv: nonce,255 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,256 tagLength: 128,257 },258 recoveryKey,259 encoded260 );261 262 const rawU8 = new Uint8Array(raw);263 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));264 const tag = hex(rawU8.slice(rawU8.length - 16));265266 return {267 rawRootKey: hex(rootKeyBytes),268 recoverySalt: hex(recoverySalt),269 wrappedRootKey: ciphertext,270 wrappedNonce: hex(nonce),271 wrappedTag: tag,272 };273}274275276277278279280export async function unwrapRootKeyForRecovery(281 wrappedRootKey: string,282 wrappedNonce: string,283 wrappedTag: string,284 recoverySaltHex: string,285 words: string[]286): Promise<void> {287 const wordMaterial = await wordsToRootKeyMaterial(words);288 const recoverySalt = unhex(recoverySaltHex);289 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);290291 292 293 294 const combined = unhex(wrappedRootKey + wrappedNonce + wrappedTag);295 const nonceU8 = combined.slice(0, 12);296 const ciphertext = combined.slice(12, combined.length - 16);297 const storedTag = combined.slice(combined.length - 16);298 const raw = await crypto.subtle.decrypt(299 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },300 recoveryKey,301 new Uint8Array([...ciphertext, ...storedTag])302 );303 const decryptedHex = new TextDecoder().decode(raw);304 rootKey = unhex(decryptedHex);305}306307308309310311312export async function rewrapRootKeyForPassword(313 newDerivedKey: Uint8Array,314 _newSalt: Uint8Array315): Promise<void> {316 if (!rootKey) throw new Error("root key not set");317 const { nonce, ciphertext, tag } = await encrypt(318 newDerivedKey,319 hex(rootKey)320 );321 const slotA = await loadSlot(SLOT_A);322 if (!slotA) throw new Error("missing slot A");323 await writeSlot(SLOT_A, {324 ...slotA,325 recovery_wrapped_root_key: ciphertext,326 recovery_nonce: nonce,327 recovery_tag: tag,328 329 330 });331}332333334335336337338async function reencryptVaultForNewKey(339 newKey: Uint8Array,340 slotA: StoredVault341): Promise<void> {342 const plaintext = encodePayload(entries, duressConfigured);343 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);344 await writeSlot(SLOT_A, {345 ...slotA,346 nonce,347 ciphertext,348 tag,349 });350}351352353354355356357export async function changePasswordWithNewKey(358 newSalt: Uint8Array,359 newKey: Uint8Array360): Promise<void> {361 const slotA = await loadSlot(SLOT_A);362 if (!slotA) throw new Error("missing slot A");363 await reencryptVaultForNewKey(newKey, slotA);364 zeroKey(derivedKey!);365 derivedKey = newKey;366 if (!duressConfigured) {367 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));368 }369}370371372373374375376export { deriveKey, generateSalt };377378export async function loadSlotA(): Promise<StoredVault | null> {379 return loadSlot(SLOT_A);380}381382383384385386387388389390391392393394395396397398399async function deriveRootKeyFromDerivedKey(400 _derivedKey: Uint8Array,401 _recoverySaltHex: string402): Promise<Uint8Array | null> {403 404 405 406 407 408 409 410 return null;411}412413414415416417418419420export async function migrateToRecovery(): Promise<void> {421 if (!derivedKey || vaultMode !== "real") {422 throw new Error("vault must be unlocked in real mode");423 }424 const slotA = await loadSlot(SLOT_A);425 if (!slotA) throw new Error("missing slot A");426 if (slotA.recovery_salt && slotA.recovery_wrapped_root_key) return; 427428 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =429 await generateAndWrapRootKey();430431 rootKey = unhex(rawRootKey);432433 const nextSlotA: StoredVault = {434 ...slotA,435 recovery_salt: recoverySalt,436 recovery_wrapped_root_key: wrappedRootKey,437 recovery_nonce: wrappedNonce,438 recovery_tag: wrappedTag,439 };440 await writeSlot(SLOT_A, nextSlotA);441}442443444445446447448export async function getRecoveryWords(): Promise<string[]> {449 if (!rootKey) {450 throw new Error("recovery not set up — vault may need migration");451 }452 return rootKeyToWords(rootKey);453}454455async function loadSlot(key: string): Promise<StoredVault | null> {456 const result = await chrome.storage.local.get(key);457 return (result as Record<string, StoredVault | undefined>)[key] ?? null;458}459460async function writeSlot(key: string, vault: StoredVault): Promise<void> {461 try {462 await chrome.storage.local.set({ [key]: vault });463 } catch (err) {464 throw mapStorageError(err);465 }466}467468469470471472473async function ensureMigrated(): Promise<void> {474 if (migrationRan) return;475 migrationRan = true;476477 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];478 const raw = await chrome.storage.local.get(keys);479 const slotAExists = raw[SLOT_A] !== undefined;480 const slotBExists = raw[SLOT_B] !== undefined;481 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;482 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;483484 485 if (!slotAExists && !legacyMain) return;486487 488 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;489490 if (legacyMain && !slotAExists) {491 await writeSlot(SLOT_A, legacyMain);492 }493494 495 if (!slotBExists) {496 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;497 if (legacyDecoy) {498 await writeSlot(SLOT_B, legacyDecoy);499 } else if (target) {500 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));501 }502 }503504 const toRemove: string[] = [];505 if (legacyMain) toRemove.push(LEGACY_MAIN);506 if (legacyDecoy) toRemove.push(LEGACY_DECOY);507 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);508 if (toRemove.length > 0) {509 await chrome.storage.local.remove(toRemove);510 }511}512513514515516517518519520521522523function padPlaintext(json: string): string {524 const encoded = new TextEncoder().encode(json);525 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);526 const padLen = target - encoded.length;527 return json + " ".repeat(padLen);528}529530531532533534535536537538539540const CSPRNG_CHUNK = 65_536;541542function fillRandom(buf: Uint8Array): Uint8Array {543 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {544 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);545 const chunk = new Uint8Array(end - offset);546 crypto.getRandomValues(chunk);547 buf.set(chunk, offset);548 }549 return buf;550}551552function generateNoiseBlob(ciphertextBytes: number): StoredVault {553 const salt = crypto.getRandomValues(new Uint8Array(16));554 const nonce = crypto.getRandomValues(new Uint8Array(12));555 const cipher = fillRandom(new Uint8Array(ciphertextBytes));556 const tag = crypto.getRandomValues(new Uint8Array(16));557 return {558 version: 1,559 salt: hex(salt),560 nonce: hex(nonce),561 ciphertext: hex(cipher),562 tag: hex(tag),563 };564}565566567568569570type VaultPayload = {571 entries: VaultEntry[];572 duressConfigured?: boolean;573};574575type DecryptedPayload = {576 entries: VaultEntry[];577 duressConfigured: boolean;578 579580581582583584585 migratedFromLegacy: boolean;586};587588function encodePayload(entries: VaultEntry[], duress: boolean): string {589 const envelope: VaultPayload = { entries, duressConfigured: duress };590 return padPlaintext(JSON.stringify(envelope));591}592593function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {594 if ("created" in raw || "updated" in raw) return true;595 const flat = raw as Partial<VaultEntry>;596 if (flat.type === "passkey") return true;597 598 if (flat.rpId !== undefined && flat.passkey === undefined) return true;599 return false;600}601602function decodePayload(plain: string): DecryptedPayload {603 const parsed = JSON.parse(plain) as604 | Array<VaultEntry | LegacyVaultEntry>605 | VaultPayload;606607 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];608 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);609610 const normalized = rawEntries.map(normalizeEntry);611 const { merged } = mergePasskeyOnlyIntoLogins(normalized);612613 if (Array.isArray(parsed)) {614 return {615 entries: normalized,616 duressConfigured: false,617 migratedFromLegacy: true, 618 };619 }620621 return {622 entries: normalized,623 duressConfigured: parsed.duressConfigured === true,624 migratedFromLegacy: flatMigrationNeeded || merged,625 };626}627628629630631632633634635636637638639640641642643644645646647648649650651function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {652 let merged = false;653 const now = Date.now();654655 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);656 const byRpId = new Map<string, VaultEntry[]>();657 for (const e of candidates) {658 const rpId = e.passkey!.rpId;659 const list = byRpId.get(rpId);660 if (list) list.push(e);661 else byRpId.set(rpId, [e]);662 }663664 for (const [rpId, group] of byRpId) {665 if (group.length === 0) continue;666 group.sort((a, b) => {667 const aT =668 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;669 const bT =670 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;671 return bT - aT;672 });673 const winner = group[0]!;674675 676 677 678 const loginTarget = entries.find((e) => {679 if (e.deletedAt || e.passkey || e.type !== "login") return false;680 if (e.url && extractDomain(e.url) === rpId) return true;681 for (const extra of e.additionalUrls ?? []) {682 if (extractDomain(extra) === rpId) return true;683 }684 return false;685 });686687 if (loginTarget) {688 loginTarget.passkey = winner.passkey;689 loginTarget.modifiedAt = now;690 for (const dup of group) {691 if (!dup.deletedAt) {692 dup.deletedAt = now;693 dup.modifiedAt = now;694 }695 }696 merged = true;697 } else if (group.length > 1) {698 for (let i = 1; i < group.length; i += 1) {699 const dup = group[i]!;700 if (!dup.deletedAt) {701 dup.deletedAt = now;702 dup.modifiedAt = now;703 }704 }705 merged = true;706 }707 }708709 return { merged };710}711712713714715716717export async function tryDecryptSlot(718 vault: StoredVault,719 key: Uint8Array720): Promise<DecryptedPayload | null> {721 try {722 const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);723 return decodePayload(plain);724 } catch {725 return null;726 }727}728729730731732733function buildDomainIndexSync(all: VaultEntry[]): void {734 domainIndex = new Map();735736 for (const entry of all) {737 if (entry.deletedAt) continue;738 if (entry.type === "note" || entry.type === "card") continue;739740 741 742 743 744 const domains = new Set<string>();745 const primary = extractDomain(entry.url ?? "");746 if (primary) domains.add(primary);747 for (const extra of entry.additionalUrls ?? []) {748 const d = extractDomain(extra);749 if (d) domains.add(d);750 }751 if (domains.size === 0) continue;752753 for (const domain of domains) {754 const bucket = domainIndex.get(domain) ?? [];755 bucket.push(entry);756 domainIndex.set(domain, bucket);757 }758 }759}760761export async function createVault(password: string): Promise<void> {762 await ensureMigrated();763764 const salt = generateSalt();765 derivedKey = await deriveKey(password, salt);766 entries = [];767 vaultMode = "real";768 duressConfigured = false;769 buildDomainIndexSync(entries);770771 772 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =773 await generateAndWrapRootKey();774 rootKey = unhex(rawRootKey);775776 const plaintext = encodePayload(entries, duressConfigured);777 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);778779 const slotA: StoredVault = {780 version: 1,781 salt: hex(salt),782 nonce,783 ciphertext,784 tag,785 recovery_salt: recoverySalt,786 recovery_wrapped_root_key: wrappedRootKey,787 recovery_nonce: wrappedNonce,788 recovery_tag: wrappedTag,789 };790 await writeSlot(SLOT_A, slotA);791792 793 794 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));795796 touchAutoLock();797}798799export async function hasVault(): Promise<boolean> {800 await ensureMigrated();801 return (await loadSlot(SLOT_A)) !== null;802}803804805806807808809export async function isDuressEnabled(): Promise<boolean> {810 return vaultMode === "real" && duressConfigured;811}812813export async function setupDuress(duressPassword: string): Promise<void> {814 if (!derivedKey || vaultMode !== "real") {815 throw new Error("vault must be unlocked in real mode");816 }817 await ensureMigrated();818819 820 821 822 const slotA = await loadSlot(SLOT_A);823 if (!slotA) throw new Error("missing slot A");824825 const decoySalt = generateSalt();826 const decoyKey = await deriveKey(duressPassword, decoySalt);827 try {828 829 830 const targetBytes = slotA.ciphertext.length / 2;831 let plaintext = encodePayload([], false);832 const encoded = new TextEncoder().encode(plaintext);833 if (encoded.length < targetBytes) {834 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);835 }836 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);837 await writeSlot(SLOT_B, {838 version: 1,839 salt: hex(decoySalt),840 nonce,841 ciphertext,842 tag,843 });844 } finally {845 zeroKey(decoyKey);846 }847848 849 850 duressConfigured = true;851 await persistSlotA();852}853854export async function disableDuress(): Promise<void> {855 if (!derivedKey || vaultMode !== "real") {856 throw new Error("vault must be unlocked in real mode");857 }858 await ensureMigrated();859860 861 862 863 const slotA = await loadSlot(SLOT_A);864 if (slotA) {865 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));866 }867868 duressConfigured = false;869 await persistSlotA();870}871872export async function verifyMainPassword(password: string): Promise<boolean> {873 await ensureMigrated();874 const slotA = await loadSlot(SLOT_A);875 if (!slotA) return false;876 const key = await deriveKey(password, unhex(slotA.salt));877 try {878 return (await tryDecryptSlot(slotA, key)) !== null;879 } finally {880 zeroKey(key);881 }882}883884export async function unlock(password: string): Promise<boolean> {885 await ensureMigrated();886887 888 889 890 891 892 const [slotA, slotB] = await Promise.all([893 loadSlot(SLOT_A),894 loadSlot(SLOT_B),895 ]);896897 898 const [keyA, keyB] = await Promise.all([899 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),900 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),901 ]);902903 904 const [payloadA, payloadB] = await Promise.all([905 keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),906 keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),907 ]);908909 if (payloadA && keyA) {910 derivedKey = keyA;911 vaultMode = "real";912 entries = payloadA.entries;913 duressConfigured = payloadA.duressConfigured;914 buildDomainIndexSync(entries);915 await loadSettings();916 touchAutoLock();917 if (keyB) zeroKey(keyB);918919 920 if (slotA) {921 const needsRecoverySetup =922 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;923 if (!needsRecoverySetup) {924 rootKey = await deriveRootKeyFromDerivedKey(925 keyA,926 slotA.recovery_salt!927 );928 }929 }930931 932 933 934 if (payloadA.migratedFromLegacy) {935 await persistSlotA();936 }937 return true;938 }939940 if (payloadB && keyB) {941 derivedKey = keyB;942 vaultMode = "decoy";943 entries = payloadB.entries;944 duressConfigured = false;945 buildDomainIndexSync(entries);946 await loadSettings();947 touchAutoLock();948 if (keyA) zeroKey(keyA);949 return true;950 }951952 if (keyA) zeroKey(keyA);953 if (keyB) zeroKey(keyB);954 return false;955}956957export function lock(): void {958 if (derivedKey) {959 zeroKey(derivedKey);960 derivedKey = null;961 }962 if (rootKey) {963 rootKey.fill(0);964 rootKey = null;965 }966967 entries = [];968 domainIndex = new Map();969 vaultMode = "real";970 duressConfigured = false;971 clearAutoLock();972}973974export function isUnlocked(): boolean {975 return derivedKey !== null;976}977978979980export async function unlockWithKey(981 masterKeyHex: string,982): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {983 try {984 const masterKeyBytes = unhex(masterKeyHex);985 const stored = await loadSlot(SLOT_A);986 if (!stored) return { ok: false, error: "invalid_key" };987 const payload = await tryDecryptSlot(stored, masterKeyBytes);988 if (!payload) return { ok: false, error: "invalid_key" };989990 derivedKey = masterKeyBytes;991 vaultMode = "real";992 entries = payload.entries;993 duressConfigured = payload.duressConfigured;994 buildDomainIndexSync(entries);995 await loadSettings();996 touchAutoLock();997 return { ok: true, entries };998 } catch {999 return { ok: false, error: "invalid_key" };1000 }1001}10021003100410051006100710081009101010111012101310141015101610171018101910201021export async function unlockFromCodex(params: {1022 slotA: string; 1023 slotB: string | null; 1024 timestamp: number;1025 masterPassphrase: string;1026}): Promise<1027 | { ok: true; entryCount: number }1028 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1029> {1030 let parsedSlotA: StoredVault;1031 let parsedSlotB: StoredVault | null = null;1032 try {1033 parsedSlotA = JSON.parse(params.slotA) as StoredVault;1034 if (params.slotB !== null) {1035 parsedSlotB = JSON.parse(params.slotB) as StoredVault;1036 }1037 1038 1039 1040 if (1041 typeof parsedSlotA?.salt !== "string" ||1042 typeof parsedSlotA?.nonce !== "string" ||1043 typeof parsedSlotA?.ciphertext !== "string" ||1044 typeof parsedSlotA?.tag !== "string"1045 ) {1046 return { ok: false, reason: "invalid_slot_format" };1047 }1048 } catch {1049 return { ok: false, reason: "invalid_slot_format" };1050 }10511052 1053 1054 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1055 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey);1056 if (!trialPayload) {1057 zeroKey(trialKey);1058 return { ok: false, reason: "wrong_passphrase" };1059 }1060 1061 1062 zeroKey(trialKey);10631064 1065 1066 await takeVaultSnapshot("restore");10671068 await writeSlot(SLOT_A, parsedSlotA);1069 if (parsedSlotB) {1070 await writeSlot(SLOT_B, parsedSlotB);1071 } else {1072 1073 1074 1075 await writeSlot(1076 SLOT_B,1077 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1078 );1079 }1080 await chrome.storage.local.set({1081 peach_last_modified_at: String(params.timestamp),1082 });10831084 1085 1086 const ok = await unlock(params.masterPassphrase);1087 if (!ok) {1088 1089 1090 1091 return { ok: false, reason: "wrong_passphrase" };1092 }1093 return { ok: true, entryCount: getEntries().length };1094}1095109610971098export function getDerivedKeyHex(): string {1099 if (!derivedKey) throw new Error("vault_not_unlocked");1100 return hex(derivedKey);1101}11021103export function getEntries(): VaultEntry[] {1104 if (!derivedKey) throw new Error("vault locked");1105 return entries.filter((entry) => !entry.deletedAt);1106}11071108const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;11091110export function getIdentities(): SavedIdentity[] {1111 if (!derivedKey) throw new Error("vault locked");1112 const active = entries.filter((e) => !e.deletedAt && e.type === "login");11131114 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1115 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();11161117 for (const entry of active) {1118 const value = entry.username?.trim();1119 if (!value) continue;1120 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;1121 const existing = map.get(value);1122 const domain = extractDomain(entry.url ?? "") ?? "";1123 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;1124 if (!existing || modifiedAt > existing.lastUsed) {1125 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1126 } else {1127 map.set(value, { ...existing, count: existing.count + 1 });1128 }1129 }11301131 const toIdentity = (type: "email" | "username") =>1132 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1133 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1134 });11351136 return [1137 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1138 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1139 ];1140}11411142export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1143 if (!derivedKey) throw new Error("vault locked");11441145 const now = Date.now();1146 const added: VaultEntry[] = [];1147 for (const draft of drafts) {1148 try {1149 const sanitized = sanitizeEntryDraft(draft);1150 const nextEntry: VaultEntry = {1151 ...sanitized,1152 id: crypto.randomUUID(),1153 createdAt: now,1154 modifiedAt: now,1155 };1156 entries.push(nextEntry);1157 added.push(nextEntry);1158 } catch {1159 1160 }1161 }11621163 if (added.length > 0) {1164 await persistEntries();1165 }11661167 return added;1168}116911701171117211731174117511761177export async function restoreEntries(1178 incoming: VaultEntry[],1179 mode: "replace" | "merge"1180): Promise<{ added: number; updated: number; skipped: number }> {1181 if (!derivedKey) throw new Error("vault locked");11821183 1184 1185 1186 if (mode === "replace" && incoming.length === 0) {1187 throw new Error("refuse to replace with empty entry set");1188 }11891190 let added = 0;1191 let updated = 0;1192 let skipped = 0;11931194 if (mode === "replace") {1195 entries = [];1196 for (const entry of incoming) {1197 const normalized = sanitizeRestoredEntry(entry);1198 if (!normalized) {1199 skipped += 1;1200 continue;1201 }1202 entries.push(normalized);1203 added += 1;1204 }1205 } else {1206 const byId = new Map<string, VaultEntry>();1207 for (const e of entries) byId.set(e.id, e);12081209 for (const entry of incoming) {1210 const normalized = sanitizeRestoredEntry(entry);1211 if (!normalized) {1212 skipped += 1;1213 continue;1214 }1215 const existing = byId.get(normalized.id);1216 if (!existing) {1217 entries.push(normalized);1218 added += 1;1219 } else if (normalized.modifiedAt > existing.modifiedAt) {1220 const idx = entries.findIndex((e) => e.id === existing.id);1221 if (idx !== -1) {1222 entries[idx] = normalized;1223 updated += 1;1224 }1225 } else {1226 skipped += 1;1227 }1228 }1229 }12301231 await persistEntries("import");1232 return { added, updated, skipped };1233}1234123512361237123812391240function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1241 if (!entry || typeof entry !== "object") return null;1242 const title = typeof entry.title === "string" ? entry.title.trim() : "";1243 if (!title) return null;1244 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1245 const now = Date.now();1246 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1247 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;12481249 1250 1251 1252 const migrated: VaultEntry = migrateLegacyPasskey(1253 {1254 ...entry,1255 id,1256 title,1257 type: entry.type ?? "login",1258 createdAt,1259 modifiedAt,1260 },1261 entry1262 );12631264 1265 1266 if (1267 migrated.type === "login" &&1268 !migrated.passkey &&1269 typeof migrated.password !== "string"1270 ) {1271 return null;1272 }1273 if (migrated.passkey && !migrated.passkey.credentialId) {1274 return null;1275 }12761277 return migrated;1278}127912801281128212831284export async function clearVaultEntries(1285 confirmation: "REPLACE_ALL"1286): Promise<void> {1287 if (confirmation !== "REPLACE_ALL") {1288 throw new Error("clearVaultEntries requires explicit confirmation");1289 }1290 if (!derivedKey) throw new Error("vault locked");1291 for (const entry of entries) {1292 if (!entry.deletedAt) {1293 entry.deletedAt = Date.now();1294 entry.modifiedAt = Date.now();1295 }1296 }1297 await persistEntries();1298}12991300export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1301 if (!derivedKey) throw new Error("vault locked");13021303 const sanitized = sanitizeEntryDraft(entry);1304 const now = Date.now();1305 const nextEntry: VaultEntry = {1306 ...sanitized,1307 id: crypto.randomUUID(),1308 createdAt: now,1309 modifiedAt: now,1310 1311 1312 passwordChangedAt: sanitized.password ? now : undefined,1313 };13141315 entries.push(nextEntry);1316 await persistEntries();13171318 1319 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1320 checkBreach(nextEntry.password).then(async (count) => {1321 if (count > 0) {1322 const idx = entries.findIndex((e) => e.id === nextEntry.id);1323 if (idx !== -1) {1324 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1325 await persistEntries();1326 }1327 }1328 });1329 }13301331 return nextEntry;1332}13331334export async function updateEntry(1335 id: string,1336 patch: Partial<VaultEntry>1337): Promise<void> {1338 if (!derivedKey) throw new Error("vault locked");13391340 const idx = entries.findIndex((entry) => entry.id === id);1341 if (idx === -1 || entries[idx].deletedAt) {1342 throw new Error("entry not found");1343 }13441345 const current = entries[idx];1346 const sanitizedPatch = sanitizeEntryPatch(patch);1347 1348 1349 1350 const passwordChanged =1351 "password" in patch &&1352 typeof sanitizedPatch.password === "string" &&1353 sanitizedPatch.password !== current.password;1354 entries[idx] = {1355 ...current,1356 ...sanitizedPatch,1357 modifiedAt: Date.now(),1358 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1359 };13601361 1362 1363 1364 1365 if (1366 entries[idx].type === "login" &&1367 !entries[idx].password &&1368 !entries[idx].passkey1369 ) {1370 entries[idx] = current;1371 throw new Error("password or passkey required");1372 }13731374 1375 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1376 entries[idx] = current;1377 throw new Error("card number is required for card entries");1378 }13791380 await persistEntries();13811382 1383 1384 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1385 entries[idx] = { ...entries[idx], breach: undefined };1386 await persistEntries();1387 const password = entries[idx].password;1388 if (password) {1389 checkBreach(password).then(async (count) => {1390 const i = entries.findIndex((e) => e.id === id);1391 if (i !== -1 && count > 0) {1392 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1393 await persistEntries();1394 }1395 });1396 }1397 }1398}13991400export async function deleteEntry(id: string): Promise<void> {1401 if (!derivedKey) throw new Error("vault locked");14021403 const entry = entries.find((item) => item.id === id);1404 if (!entry || entry.deletedAt) {1405 throw new Error("entry not found");1406 }14071408 entry.deletedAt = Date.now();1409 entry.modifiedAt = Date.now();1410 await persistEntries();1411}14121413export function getDeletedEntries(): VaultEntry[] {1414 if (!derivedKey) return [];1415 return entries.filter((e) => !!e.deletedAt);1416}14171418export async function restoreEntry(id: string): Promise<void> {1419 if (!derivedKey) throw new Error("vault locked");1420 const entry = entries.find((e) => e.id === id && e.deletedAt);1421 if (!entry) throw new Error("deleted entry not found");1422 delete entry.deletedAt;1423 entry.modifiedAt = Date.now();1424 await persistEntries();1425 buildDomainIndexSync(entries);1426}14271428export async function purgeEntry(id: string): Promise<void> {1429 if (!derivedKey) throw new Error("vault locked");1430 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1431 if (idx === -1) throw new Error("deleted entry not found");1432 entries.splice(idx, 1);1433 await persistEntries();1434}143514361437export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1438 if (!derivedKey || retentionMs === 0) return 0;1439 const cutoff = Date.now() - retentionMs;1440 const before = entries.length;1441 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1442 const purged = before - entries.length;1443 if (purged > 0) await persistEntries();1444 return purged;1445}14461447export function queryDomain(domain: string): VaultEntry[] {1448 return domainIndex.get(domain) ?? [];1449}14501451export function getEntryById(id: string): VaultEntry | undefined {1452 return entries.find((entry) => entry.id === id && !entry.deletedAt);1453}14541455async function persistSlotA(1456 reason: VaultSnapshot["reason"] = "local_save",1457): Promise<StoredVault> {1458 if (!derivedKey) throw new Error("vault locked");14591460 const slotA = await loadSlot(SLOT_A);1461 if (!slotA) throw new Error("missing slot A");14621463 1464 1465 await takeVaultSnapshot(reason);14661467 const plaintext = encodePayload(entries, duressConfigured);1468 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);14691470 1471 const nextSlotA: StoredVault = {1472 ...slotA,1473 nonce,1474 ciphertext,1475 tag,1476 1477 };1478 await writeSlot(SLOT_A, nextSlotA);14791480 1481 1482 1483 if (!duressConfigured) {1484 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1485 }14861487 return nextSlotA;1488}14891490async function persistSlotB(): Promise<void> {1491 if (!derivedKey) throw new Error("vault locked");14921493 const slotB = await loadSlot(SLOT_B);1494 if (!slotB) throw new Error("missing slot B");14951496 1497 1498 const plaintext = encodePayload(entries, false);1499 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);15001501 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1502}15031504async function persistEntries(1505 reason: VaultSnapshot["reason"] = "local_save",1506): Promise<void> {1507 if (!derivedKey) throw new Error("vault locked");15081509 if (vaultMode === "decoy") {1510 await persistSlotB();1511 } else {1512 await persistSlotA(reason);1513 }1514 buildDomainIndexSync(entries);1515}15161517function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1518 if (!Array.isArray(values)) return undefined;1519 const seen = new Set<string>();1520 const cleaned: string[] = [];1521 for (const value of values) {1522 const trimmed = typeof value === "string" ? value.trim() : "";1523 if (!trimmed) continue;1524 const key = trimmed.toLowerCase();1525 if (seen.has(key)) continue;1526 seen.add(key);1527 cleaned.push(trimmed);1528 }1529 return cleaned.length > 0 ? cleaned : undefined;1530}15311532function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1533 const title = entry.title.trim();1534 1535 1536 1537 let type: VaultEntry["type"] = entry.type ?? "login";1538 if (type === "passkey") type = "login";15391540 if (!title) throw new Error("title is required");1541 1542 1543 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1544 throw new Error("password or passkey required");1545 }1546 if (entry.passkey) {1547 const p = entry.passkey;1548 if (1549 !p.rpId ||1550 !p.credentialId ||1551 !p.publicKeyCose ||1552 !p.userHandle ||1553 !p.userName ||1554 (p.algorithm !== -7 && p.algorithm !== -8)1555 ) {1556 throw new Error("invalid passkey credential");1557 }1558 }1559 if (type === "card") {1560 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1561 throw new Error("cardholder name and card number are required");1562 }1563 }15641565 return {1566 type,1567 title,1568 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1569 password: type === "login" ? entry.password : undefined,1570 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1571 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1572 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1573 notes: entry.notes?.trim() || undefined,1574 breach: type === "login" ? entry.breach : undefined,1575 canary: type === "login" ? entry.canary : undefined,1576 tags: entry.tags,1577 passkey: entry.passkey,1578 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1579 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1580 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1581 expYear: type === "card" ? entry.expYear?.trim() : undefined,1582 cvc: type === "card" ? entry.cvc?.trim() : undefined,1583 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1584 network: type === "card" ? entry.network : undefined,1585 last4: type === "card" ? entry.last4?.trim() : undefined,1586 };1587}15881589function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1590 const nextPatch: Partial<VaultEntry> = {};15911592 if ("type" in patch) {1593 nextPatch.type = patch.type;1594 }15951596 if ("title" in patch) {1597 const title = patch.title?.trim() ?? "";1598 if (!title) throw new Error("title is required");1599 nextPatch.title = title;1600 }16011602 if ("url" in patch) {1603 nextPatch.url = patch.url?.trim() ?? "";1604 }16051606 if ("additionalUrls" in patch) {1607 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1608 const seen = new Set<string>();1609 const cleaned: string[] = [];1610 for (const value of incoming) {1611 const trimmed = typeof value === "string" ? value.trim() : "";1612 if (!trimmed) continue;1613 const key = trimmed.toLowerCase();1614 if (seen.has(key)) continue;1615 seen.add(key);1616 cleaned.push(trimmed);1617 }1618 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1619 }16201621 if ("username" in patch) {1622 nextPatch.username = patch.username?.trim() ?? "";1623 }16241625 if ("email" in patch) {1626 nextPatch.email = patch.email?.trim() || undefined;1627 }16281629 if ("password" in patch) {1630 const password = patch.password ?? "";1631 1632 nextPatch.password = password || undefined;1633 }16341635 if ("totp" in patch) {1636 nextPatch.totp = patch.totp?.trim() || undefined;1637 }16381639 if ("notes" in patch) {1640 nextPatch.notes = patch.notes?.trim() || undefined;1641 }16421643 if ("breach" in patch) {1644 nextPatch.breach = patch.breach;1645 }16461647 if ("canary" in patch) {1648 nextPatch.canary = patch.canary;1649 }16501651 if ("passkey" in patch) {1652 1653 1654 1655 nextPatch.passkey = patch.passkey;1656 }16571658 if ("tags" in patch) {1659 nextPatch.tags = patch.tags;1660 }16611662 if ("cardholderName" in patch) {1663 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1664 }1665 if ("cardNumber" in patch) {1666 const n = patch.cardNumber ?? "";1667 nextPatch.cardNumber = n || undefined;1668 }1669 if ("expMonth" in patch) {1670 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1671 }1672 if ("expYear" in patch) {1673 nextPatch.expYear = patch.expYear?.trim() || undefined;1674 }1675 if ("cvc" in patch) {1676 nextPatch.cvc = patch.cvc?.trim() || undefined;1677 }1678 if ("cardNickname" in patch) {1679 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1680 }1681 if ("network" in patch) {1682 nextPatch.network = patch.network;1683 }1684 if ("last4" in patch) {1685 nextPatch.last4 = patch.last4?.trim() || undefined;1686 }16871688 return nextPatch;1689}16901691function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1692 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1693 const modifiedAt =1694 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;16951696 const base: VaultEntry = {1697 id: entry.id,1698 type: "type" in entry ? entry.type : "login",1699 title: entry.title,1700 username: entry.username,1701 password: entry.password,1702 url: entry.url,1703 totp: entry.totp,1704 notes: entry.notes,1705 breach: entry.breach,1706 canary: "canary" in entry ? entry.canary : undefined,1707 passkey: "passkey" in entry ? entry.passkey : undefined,1708 createdAt,1709 modifiedAt,1710 deletedAt: entry.deletedAt,1711 tags: "tags" in entry ? entry.tags : undefined,1712 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1713 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1714 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1715 expYear: "expYear" in entry ? entry.expYear : undefined,1716 cvc: "cvc" in entry ? entry.cvc : undefined,1717 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1718 network: "network" in entry ? entry.network : undefined,1719 last4: "last4" in entry ? entry.last4 : undefined,1720 };17211722 return migrateLegacyPasskey(base, entry);1723}172417251726172717281729173017311732function migrateLegacyPasskey(1733 base: VaultEntry,1734 raw: VaultEntry | LegacyVaultEntry1735): VaultEntry {1736 if (base.passkey) return base;1737 const flat = raw as Partial<VaultEntry>;1738 const isPasskeyType = base.type === "passkey";1739 const hasFlatPasskeyFields =1740 typeof flat.rpId === "string" &&1741 typeof flat.credentialId === "string" &&1742 typeof flat.publicKeyCose === "string" &&1743 typeof flat.userHandle === "string" &&1744 typeof flat.userName === "string";1745 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1746 if (!hasFlatPasskeyFields) return base;17471748 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1749 base.passkey = {1750 rpId: flat.rpId!,1751 rpName: flat.rpName ?? flat.rpId!,1752 credentialId: flat.credentialId!,1753 publicKeyCose: flat.publicKeyCose!,1754 privateKeyJwk: flat.privateKeyJwk,1755 userHandle: flat.userHandle!,1756 userName: flat.userName!,1757 userDisplayName: flat.userDisplayName ?? flat.userName!,1758 algorithm,1759 signCount: flat.signCount ?? 0,1760 aaguid: flat.aaguid ?? "",1761 backupEligible: flat.backupEligible === true,1762 backupState: flat.backupState === true,1763 lastUsedAt: flat.lastUsedAt,1764 browser: flat.browser,1765 transports: flat.transports,1766 createdAt: base.createdAt,1767 };1768 1769 1770 1771 if (isPasskeyType) base.type = "login";1772 if (!base.username) base.username = flat.userName;1773 if (!base.url) base.url = `https://${flat.rpId}`;1774 return base;1775}1776177717781779178017811782function entryDupeKey(e: VaultEntry): string {1783 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1784 1785 1786 1787 1788 if (e.passkey) {1789 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1790 }1791 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1792}179317941795export function countDuplicates(): number {1796 const best = new Map<string, VaultEntry>();1797 let dupeCount = 0;1798 for (const e of entries.filter((e) => !e.deletedAt)) {1799 const key = entryDupeKey(e);1800 const existing = best.get(key);1801 if (!existing) {1802 best.set(key, e);1803 } else {1804 dupeCount++;1805 1806 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1807 best.set(key, e);1808 }1809 }1810 }1811 return dupeCount;1812}181318141815181618171818export async function deduplicateEntries(): Promise<number> {1819 if (!derivedKey) throw new Error("vault locked");1820 1821 1822 if (vaultMode === "decoy") {1823 throw new Error("deduplicate disabled in decoy mode");1824 }18251826 const best = new Map<string, VaultEntry>();1827 for (const e of entries.filter((en) => !en.deletedAt)) {1828 const key = entryDupeKey(e);1829 const existing = best.get(key);1830 if (!existing) {1831 best.set(key, e);1832 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1833 best.set(key, e);1834 }1835 }18361837 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1838 const now = Date.now();1839 let removed = 0;1840 entries = entries.map((e) => {1841 if (!e.deletedAt && !keepIds.has(e.id)) {1842 removed++;1843 return { ...e, deletedAt: now };1844 }1845 return e;1846 });18471848 if (removed > 0) await persistEntries();1849 return removed;1850}185118521853185418551856185718581859const HISTORY_KEY = "peach_vault_history";1860const HISTORY_MAX = 10;18611862186318641865186618671868export async function takeVaultSnapshot(1869 reason: VaultSnapshot["reason"],1870): Promise<void> {1871 try {1872 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);1873 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];1874 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];18751876 1877 1878 1879 if (!slotA) return;18801881 const vault_a = JSON.stringify(slotA);1882 const vault_b = JSON.stringify(slotB ?? slotA);18831884 1885 1886 1887 let entryCount: number | null = null;1888 if (derivedKey && vaultMode === "real") {1889 try {1890 entryCount = entries.filter((e) => !e.deletedAt).length;1891 } catch {1892 entryCount = null;1893 }1894 }18951896 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1897 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)1898 ? existingRaw1899 : { snapshots: [] };19001901 1902 1903 1904 const latest = history.snapshots[0];1905 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {1906 return;1907 }19081909 const snapshot: VaultSnapshot = {1910 id: crypto.randomUUID(),1911 savedAt: Date.now(),1912 reason,1913 vault_a,1914 vault_b,1915 entryCount,1916 };19171918 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);1919 await chrome.storage.local.set({ [HISTORY_KEY]: history });1920 } catch {1921 1922 1923 }1924}192519261927export async function clearVaultHistory(): Promise<void> {1928 try {1929 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });1930 } catch {1931 1932 }1933}193419351936export async function getVaultHistory(): Promise<VaultSnapshot[]> {1937 const raw = await chrome.storage.local.get(HISTORY_KEY);1938 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1939 if (!history || !Array.isArray(history.snapshots)) return [];1940 return history.snapshots;1941}19421943194419451946194719481949export async function restoreVaultSnapshot(1950 snapshotId: string,1951): Promise<{ ok: true } | { ok: false; error: string }> {1952 const history = await getVaultHistory();1953 const snapshot = history.find((s) => s.id === snapshotId);1954 if (!snapshot) return { ok: false, error: "snapshot_not_found" };19551956 1957 1958 1959 await takeVaultSnapshot("restore");19601961 let restoredA: StoredVault;1962 let restoredB: StoredVault;1963 try {1964 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;1965 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;1966 } catch {1967 return { ok: false, error: "snapshot_corrupt" };1968 }19691970 await writeSlot(SLOT_A, restoredA);1971 await writeSlot(SLOT_B, restoredB);19721973 1974 1975 1976 lock();19771978 return { ok: true };1979}19801981198219831984198519861987export async function changePassword(1988 currentPassword: string,1989 newPassword: string1990): Promise<boolean> {1991 if (!derivedKey || vaultMode !== "real") return false;19921993 1994 const valid = await verifyMainPassword(currentPassword);1995 if (!valid) return false;19961997 const slotA = await loadSlot(SLOT_A);1998 if (!slotA) return false;19992000 2001 const newSalt = generateSalt();2002 const newKey = await deriveKey(newPassword, newSalt);20032004 2005 const plaintext = encodePayload(entries, duressConfigured);2006 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);20072008 const freshSlotA: StoredVault = {2009 ...slotA,2010 salt: hex(newSalt),2011 nonce,2012 ciphertext,2013 tag,2014 };2015 2016 2017 2018 await takeVaultSnapshot("local_save");2019 await writeSlot(SLOT_A, freshSlotA);20202021 2022 2023 2024 zeroKey(derivedKey);2025 derivedKey = newKey;20262027 2028 2029 const {2030 rawRootKey: freshRawKey,2031 recoverySalt: freshSalt,2032 wrappedRootKey: freshWrapped,2033 wrappedNonce: freshNonce,2034 wrappedTag: freshTag,2035 } = await generateAndWrapRootKey();2036 rootKey = unhex(freshRawKey);2037 const slotAfterRekey = await loadSlot(SLOT_A);2038 if (slotAfterRekey) {2039 await writeSlot(SLOT_A, {2040 ...slotAfterRekey,2041 recovery_salt: freshSalt,2042 recovery_wrapped_root_key: freshWrapped,2043 recovery_nonce: freshNonce,2044 recovery_tag: freshTag,2045 });2046 }20472048 2049 2050 2051 if (!duressConfigured) {2052 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2053 }20542055 return true;2056}205720582059206020612062export async function getVaultBlobForSync(): Promise<{2063 blob: string;2064 timestamp: number;2065 license?: string;2066} | null> {2067 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, 'peach_last_modified_at', 'peach_license']);2068 const slotA = (raw as Record<string, unknown>)[SLOT_A];2069 if (!slotA) return null;2070 const slotB = (raw as Record<string, unknown>)[SLOT_B];2071 const blob = JSON.stringify({ a: slotA, b: slotB });2072 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2073 const timestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;2074 const license = (raw as Record<string, unknown>)['peach_license'];2075 return {2076 blob,2077 timestamp,2078 license: typeof license === 'string' ? license : undefined,2079 };2080}20812082export async function applyIncomingVault(2083 blobJson: string,2084 remoteTimestamp: number,2085 remoteLicense?: string,2086): Promise<boolean> {2087 const raw = await chrome.storage.local.get('peach_last_modified_at');2088 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2089 const localTimestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;20902091 if (remoteLicense) {2092 const licenseRaw = await chrome.storage.local.get('peach_license');2093 const existing = (licenseRaw as Record<string, unknown>)['peach_license'];2094 if (!existing) {2095 await chrome.storage.local.set({ peach_license: remoteLicense });2096 }2097 }20982099 if (remoteTimestamp <= localTimestamp) return false;21002101 2102 await takeVaultSnapshot('sync_received').catch(() => {});21032104 const { a, b } = JSON.parse(blobJson) as { a: unknown; b?: unknown };2105 await writeSlot(SLOT_A, a as StoredVault);2106 if (b) await writeSlot(SLOT_B, b as StoredVault);2107 await chrome.storage.local.set({2108 peach_last_sync_at: String(Date.now()),2109 peach_last_modified_at: String(remoteTimestamp),2110 });21112112 return true;2113}21142115export async function getSyncIdentity(): Promise<{2116 privateKeyBytes: Uint8Array;2117 publicKeyBytes: Uint8Array;2118 publicKeyHex: string;2119}> {2120 const raw = await chrome.storage.local.get('peach_sync_identity');2121 const stored = (raw as Record<string, unknown>)['peach_sync_identity'];2122 if (typeof stored === 'string') {2123 const { priv, pub } = JSON.parse(stored) as { priv: string; pub: string };2124 const privateKeyBytes = base64DecodeBytes(priv);2125 const publicKeyBytes = base64DecodeBytes(pub);2126 return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2127 }21282129 2130 const { generateKeyPair } = await import('@stablelib/x25519');2131 const keypair = generateKeyPair();2132 await chrome.storage.local.set({2133 peach_sync_identity: JSON.stringify({2134 priv: base64EncodeBytes(keypair.secretKey),2135 pub: base64EncodeBytes(keypair.publicKey),2136 }),2137 });2138 return {2139 privateKeyBytes: keypair.secretKey,2140 publicKeyBytes: keypair.publicKey,2141 publicKeyHex: bytesToHex(keypair.publicKey),2142 };2143}21442145214621472148export async function reloadEntriesAfterSync(): Promise<boolean> {2149 if (!derivedKey) return false;2150 const slotA = await loadSlot(SLOT_A);2151 if (!slotA) return false;2152 try {2153 const payload = await tryDecryptSlot(slotA, derivedKey);2154 if (!payload) return false;2155 entries = payload.entries;2156 buildDomainIndexSync(entries);2157 return true;2158 } catch {2159 return false;2160 }2161}21622163function bytesToHex(bytes: Uint8Array): string {2164 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');2165}21662167function base64EncodeBytes(bytes: Uint8Array): string {2168 let binary = '';2169 for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);2170 return btoa(binary);2171}21722173function base64DecodeBytes(b64: string): Uint8Array {2174 const binary = atob(b64);2175 const bytes = new Uint8Array(binary.length);2176 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);2177 return bytes;2178}