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 106107108109110111112 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;113}114115let settings: VaultSettings = {};116117export function getSettings(): VaultSettings {118 return settings;119}120121export async function ensureSettingsLoaded(): Promise<VaultSettings> {122 await loadSettings();123 return settings;124}125126export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {127 await loadSettings();128 settings = { ...settings, ...patch };129 await persistSettings();130}131132async function persistSettings(): Promise<void> {133 try {134 await chrome.storage.local.set({ peach_settings: settings });135 } catch (err) {136 throw mapStorageError(err);137 }138}139140function mapStorageError(err: unknown): Error {141 const message = err instanceof Error ? err.message : String(err);142 if (/QUOTA_BYTES|quota/i.test(message)) {143 return new Error("STORAGE_QUOTA_EXCEEDED");144 }145 return err instanceof Error ? err : new Error(message);146}147148async function loadSettings(): Promise<void> {149 const result = await chrome.storage.local.get("peach_settings");150 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};151}152153154155156157export async function checkBreach(password: string): Promise<number> {158 const encoder = new TextEncoder();159 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));160 const hashHex = Array.from(new Uint8Array(hashBuffer))161 .map((b) => b.toString(16).padStart(2, "0"))162 .join("")163 .toUpperCase();164165 const prefix = hashHex.slice(0, 5);166 const suffix = hashHex.slice(5);167168 try {169 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);170 if (!res.ok) return 0;171 const text = await res.text();172 const match = text.split("\r\n").find((l) => l.startsWith(suffix));173 return match ? parseInt(match.split(":")[1], 10) : 0;174 } catch {175 return 0; 176 }177}178179export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {180 const fingerprint = await cardFingerprint(last4, expMonth, expYear);181 await loadSettings();182 settings = {183 ...settings,184 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],185 };186 await persistSettings();187}188189export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {190 await loadSettings();191 const fingerprint = await cardFingerprint(last4, expMonth, expYear);192 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);193}194195async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {196 const encoder = new TextEncoder();197 const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));198 return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");199}200201function hex(buf: Uint8Array): string {202 return Array.from(buf)203 .map((b) => b.toString(16).padStart(2, "0"))204 .join("");205}206207function unhex(s: string): Uint8Array {208 const out = new Uint8Array(s.length / 2);209 for (let i = 0; i < s.length; i += 2) {210 out[i / 2] = parseInt(s.slice(i, i + 2), 16);211 }212 return out;213}214215216217218219220221222223224225226async function generateAndWrapRootKey(): Promise<{227 rawRootKey: string; 228 recoverySalt: string;229 wrappedRootKey: string;230 wrappedNonce: string;231 wrappedTag: string;232}> {233 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));234235 236 const words = await rootKeyToWords(rootKeyBytes);237 const wordMaterial = await wordsToRootKeyMaterial(words);238 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));239 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);240241 242 243 const nonce = crypto.getRandomValues(new Uint8Array(12));244 const encoded = new TextEncoder().encode(hex(rootKeyBytes));245 const raw = await crypto.subtle.encrypt(246 {247 name: "AES-GCM",248 iv: nonce,249 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,250 tagLength: 128,251 },252 recoveryKey,253 encoded254 );255 256 const rawU8 = new Uint8Array(raw);257 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));258 const tag = hex(rawU8.slice(rawU8.length - 16));259260 return {261 rawRootKey: hex(rootKeyBytes),262 recoverySalt: hex(recoverySalt),263 wrappedRootKey: ciphertext,264 wrappedNonce: hex(nonce),265 wrappedTag: tag,266 };267}268269270271272273274export async function unwrapRootKeyForRecovery(275 wrappedRootKey: string,276 wrappedNonce: string,277 wrappedTag: string,278 recoverySaltHex: string,279 words: string[]280): Promise<void> {281 const wordMaterial = await wordsToRootKeyMaterial(words);282 const recoverySalt = unhex(recoverySaltHex);283 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);284285 286 287 288 const combined = unhex(wrappedRootKey + wrappedNonce + wrappedTag);289 const nonceU8 = combined.slice(0, 12);290 const ciphertext = combined.slice(12, combined.length - 16);291 const storedTag = combined.slice(combined.length - 16);292 const raw = await crypto.subtle.decrypt(293 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },294 recoveryKey,295 new Uint8Array([...ciphertext, ...storedTag])296 );297 const decryptedHex = new TextDecoder().decode(raw);298 rootKey = unhex(decryptedHex);299}300301302303304305306export async function rewrapRootKeyForPassword(307 newDerivedKey: Uint8Array,308 _newSalt: Uint8Array309): Promise<void> {310 if (!rootKey) throw new Error("root key not set");311 const { nonce, ciphertext, tag } = await encrypt(312 newDerivedKey,313 hex(rootKey)314 );315 const slotA = await loadSlot(SLOT_A);316 if (!slotA) throw new Error("missing slot A");317 await writeSlot(SLOT_A, {318 ...slotA,319 recovery_wrapped_root_key: ciphertext,320 recovery_nonce: nonce,321 recovery_tag: tag,322 323 324 });325}326327328329330331332async function reencryptVaultForNewKey(333 newKey: Uint8Array,334 slotA: StoredVault335): Promise<void> {336 const plaintext = encodePayload(entries, duressConfigured);337 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);338 await writeSlot(SLOT_A, {339 ...slotA,340 nonce,341 ciphertext,342 tag,343 });344}345346347348349350351export async function changePasswordWithNewKey(352 newSalt: Uint8Array,353 newKey: Uint8Array354): Promise<void> {355 const slotA = await loadSlot(SLOT_A);356 if (!slotA) throw new Error("missing slot A");357 await reencryptVaultForNewKey(newKey, slotA);358 zeroKey(derivedKey!);359 derivedKey = newKey;360 if (!duressConfigured) {361 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));362 }363}364365366367368369370export { deriveKey, generateSalt };371372export async function loadSlotA(): Promise<StoredVault | null> {373 return loadSlot(SLOT_A);374}375376377378379380381382383384385386387388389390391392393async function deriveRootKeyFromDerivedKey(394 _derivedKey: Uint8Array,395 _recoverySaltHex: string396): Promise<Uint8Array | null> {397 398 399 400 401 402 403 404 return null;405}406407408409410411412413414export async function migrateToRecovery(): Promise<void> {415 if (!derivedKey || vaultMode !== "real") {416 throw new Error("vault must be unlocked in real mode");417 }418 const slotA = await loadSlot(SLOT_A);419 if (!slotA) throw new Error("missing slot A");420 if (slotA.recovery_salt && slotA.recovery_wrapped_root_key) return; 421422 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =423 await generateAndWrapRootKey();424425 rootKey = unhex(rawRootKey);426427 const nextSlotA: StoredVault = {428 ...slotA,429 recovery_salt: recoverySalt,430 recovery_wrapped_root_key: wrappedRootKey,431 recovery_nonce: wrappedNonce,432 recovery_tag: wrappedTag,433 };434 await writeSlot(SLOT_A, nextSlotA);435}436437438439440441442export async function getRecoveryWords(): Promise<string[]> {443 if (!rootKey) {444 throw new Error("recovery not set up — vault may need migration");445 }446 return rootKeyToWords(rootKey);447}448449async function loadSlot(key: string): Promise<StoredVault | null> {450 const result = await chrome.storage.local.get(key);451 return (result as Record<string, StoredVault | undefined>)[key] ?? null;452}453454async function writeSlot(key: string, vault: StoredVault): Promise<void> {455 try {456 await chrome.storage.local.set({ [key]: vault });457 } catch (err) {458 throw mapStorageError(err);459 }460}461462463464465466467async function ensureMigrated(): Promise<void> {468 if (migrationRan) return;469 migrationRan = true;470471 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];472 const raw = await chrome.storage.local.get(keys);473 const slotAExists = raw[SLOT_A] !== undefined;474 const slotBExists = raw[SLOT_B] !== undefined;475 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;476 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;477478 479 if (!slotAExists && !legacyMain) return;480481 482 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;483484 if (legacyMain && !slotAExists) {485 await writeSlot(SLOT_A, legacyMain);486 }487488 489 if (!slotBExists) {490 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;491 if (legacyDecoy) {492 await writeSlot(SLOT_B, legacyDecoy);493 } else if (target) {494 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));495 }496 }497498 const toRemove: string[] = [];499 if (legacyMain) toRemove.push(LEGACY_MAIN);500 if (legacyDecoy) toRemove.push(LEGACY_DECOY);501 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);502 if (toRemove.length > 0) {503 await chrome.storage.local.remove(toRemove);504 }505}506507508509510511512513514515516517function padPlaintext(json: string): string {518 const encoded = new TextEncoder().encode(json);519 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);520 const padLen = target - encoded.length;521 return json + " ".repeat(padLen);522}523524525526527528529530531532533534const CSPRNG_CHUNK = 65_536;535536function fillRandom(buf: Uint8Array): Uint8Array {537 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {538 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);539 const chunk = new Uint8Array(end - offset);540 crypto.getRandomValues(chunk);541 buf.set(chunk, offset);542 }543 return buf;544}545546function generateNoiseBlob(ciphertextBytes: number): StoredVault {547 const salt = crypto.getRandomValues(new Uint8Array(16));548 const nonce = crypto.getRandomValues(new Uint8Array(12));549 const cipher = fillRandom(new Uint8Array(ciphertextBytes));550 const tag = crypto.getRandomValues(new Uint8Array(16));551 return {552 version: 1,553 salt: hex(salt),554 nonce: hex(nonce),555 ciphertext: hex(cipher),556 tag: hex(tag),557 };558}559560561562563564type VaultPayload = {565 entries: VaultEntry[];566 duressConfigured?: boolean;567};568569type DecryptedPayload = {570 entries: VaultEntry[];571 duressConfigured: boolean;572 573574575576577578579 migratedFromLegacy: boolean;580};581582function encodePayload(entries: VaultEntry[], duress: boolean): string {583 const envelope: VaultPayload = { entries, duressConfigured: duress };584 return padPlaintext(JSON.stringify(envelope));585}586587function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {588 if ("created" in raw || "updated" in raw) return true;589 const flat = raw as Partial<VaultEntry>;590 if (flat.type === "passkey") return true;591 592 if (flat.rpId !== undefined && flat.passkey === undefined) return true;593 return false;594}595596function decodePayload(plain: string): DecryptedPayload {597 const parsed = JSON.parse(plain) as598 | Array<VaultEntry | LegacyVaultEntry>599 | VaultPayload;600601 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];602 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);603604 const normalized = rawEntries.map(normalizeEntry);605 const { merged } = mergePasskeyOnlyIntoLogins(normalized);606607 if (Array.isArray(parsed)) {608 return {609 entries: normalized,610 duressConfigured: false,611 migratedFromLegacy: true, 612 };613 }614615 return {616 entries: normalized,617 duressConfigured: parsed.duressConfigured === true,618 migratedFromLegacy: flatMigrationNeeded || merged,619 };620}621622623624625626627628629630631632633634635636637638639640641642643644645function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {646 let merged = false;647 const now = Date.now();648649 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);650 const byRpId = new Map<string, VaultEntry[]>();651 for (const e of candidates) {652 const rpId = e.passkey!.rpId;653 const list = byRpId.get(rpId);654 if (list) list.push(e);655 else byRpId.set(rpId, [e]);656 }657658 for (const [rpId, group] of byRpId) {659 if (group.length === 0) continue;660 group.sort((a, b) => {661 const aT =662 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;663 const bT =664 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;665 return bT - aT;666 });667 const winner = group[0]!;668669 670 671 672 const loginTarget = entries.find((e) => {673 if (e.deletedAt || e.passkey || e.type !== "login") return false;674 if (e.url && extractDomain(e.url) === rpId) return true;675 for (const extra of e.additionalUrls ?? []) {676 if (extractDomain(extra) === rpId) return true;677 }678 return false;679 });680681 if (loginTarget) {682 loginTarget.passkey = winner.passkey;683 loginTarget.modifiedAt = now;684 for (const dup of group) {685 if (!dup.deletedAt) {686 dup.deletedAt = now;687 dup.modifiedAt = now;688 }689 }690 merged = true;691 } else if (group.length > 1) {692 for (let i = 1; i < group.length; i += 1) {693 const dup = group[i]!;694 if (!dup.deletedAt) {695 dup.deletedAt = now;696 dup.modifiedAt = now;697 }698 }699 merged = true;700 }701 }702703 return { merged };704}705706async function tryDecryptSlot(707 vault: StoredVault,708 key: Uint8Array709): Promise<DecryptedPayload | null> {710 try {711 const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);712 return decodePayload(plain);713 } catch {714 return null;715 }716}717718719720721722function buildDomainIndexSync(all: VaultEntry[]): void {723 domainIndex = new Map();724725 for (const entry of all) {726 if (entry.deletedAt) continue;727 if (entry.type === "note" || entry.type === "card") continue;728729 730 731 732 733 const domains = new Set<string>();734 const primary = extractDomain(entry.url ?? "");735 if (primary) domains.add(primary);736 for (const extra of entry.additionalUrls ?? []) {737 const d = extractDomain(extra);738 if (d) domains.add(d);739 }740 if (domains.size === 0) continue;741742 for (const domain of domains) {743 const bucket = domainIndex.get(domain) ?? [];744 bucket.push(entry);745 domainIndex.set(domain, bucket);746 }747 }748}749750export async function createVault(password: string): Promise<void> {751 await ensureMigrated();752753 const salt = generateSalt();754 derivedKey = await deriveKey(password, salt);755 entries = [];756 vaultMode = "real";757 duressConfigured = false;758 buildDomainIndexSync(entries);759760 761 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =762 await generateAndWrapRootKey();763 rootKey = unhex(rawRootKey);764765 const plaintext = encodePayload(entries, duressConfigured);766 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);767768 const slotA: StoredVault = {769 version: 1,770 salt: hex(salt),771 nonce,772 ciphertext,773 tag,774 recovery_salt: recoverySalt,775 recovery_wrapped_root_key: wrappedRootKey,776 recovery_nonce: wrappedNonce,777 recovery_tag: wrappedTag,778 };779 await writeSlot(SLOT_A, slotA);780781 782 783 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));784785 touchAutoLock();786}787788export async function hasVault(): Promise<boolean> {789 await ensureMigrated();790 return (await loadSlot(SLOT_A)) !== null;791}792793794795796797798export async function isDuressEnabled(): Promise<boolean> {799 return vaultMode === "real" && duressConfigured;800}801802export async function hasDecoyVault(): Promise<boolean> {803 return isDuressEnabled();804}805806export async function setupDuress(duressPassword: string): Promise<void> {807 if (!derivedKey || vaultMode !== "real") {808 throw new Error("vault must be unlocked in real mode");809 }810 await ensureMigrated();811812 813 814 815 const slotA = await loadSlot(SLOT_A);816 if (!slotA) throw new Error("missing slot A");817818 const decoySalt = generateSalt();819 const decoyKey = await deriveKey(duressPassword, decoySalt);820 try {821 822 823 const targetBytes = slotA.ciphertext.length / 2;824 let plaintext = encodePayload([], false);825 const encoded = new TextEncoder().encode(plaintext);826 if (encoded.length < targetBytes) {827 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);828 }829 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);830 await writeSlot(SLOT_B, {831 version: 1,832 salt: hex(decoySalt),833 nonce,834 ciphertext,835 tag,836 });837 } finally {838 zeroKey(decoyKey);839 }840841 842 843 duressConfigured = true;844 await persistSlotA();845}846847export async function disableDuress(): Promise<void> {848 if (!derivedKey || vaultMode !== "real") {849 throw new Error("vault must be unlocked in real mode");850 }851 await ensureMigrated();852853 854 855 856 const slotA = await loadSlot(SLOT_A);857 if (slotA) {858 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));859 }860861 duressConfigured = false;862 await persistSlotA();863}864865export async function verifyMainPassword(password: string): Promise<boolean> {866 await ensureMigrated();867 const slotA = await loadSlot(SLOT_A);868 if (!slotA) return false;869 const key = await deriveKey(password, unhex(slotA.salt));870 try {871 return (await tryDecryptSlot(slotA, key)) !== null;872 } finally {873 zeroKey(key);874 }875}876877export async function unlock(password: string): Promise<boolean> {878 await ensureMigrated();879880 881 882 883 884 885 const [slotA, slotB] = await Promise.all([886 loadSlot(SLOT_A),887 loadSlot(SLOT_B),888 ]);889890 891 const [keyA, keyB] = await Promise.all([892 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),893 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),894 ]);895896 897 const [payloadA, payloadB] = await Promise.all([898 keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),899 keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),900 ]);901902 if (payloadA && keyA) {903 derivedKey = keyA;904 vaultMode = "real";905 entries = payloadA.entries;906 duressConfigured = payloadA.duressConfigured;907 buildDomainIndexSync(entries);908 await loadSettings();909 touchAutoLock();910 if (keyB) zeroKey(keyB);911912 913 if (slotA) {914 const needsRecoverySetup =915 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;916 if (!needsRecoverySetup) {917 rootKey = await deriveRootKeyFromDerivedKey(918 keyA,919 slotA.recovery_salt!920 );921 }922 }923924 925 926 927 if (payloadA.migratedFromLegacy) {928 await persistSlotA();929 }930 return true;931 }932933 if (payloadB && keyB) {934 derivedKey = keyB;935 vaultMode = "decoy";936 entries = payloadB.entries;937 duressConfigured = false;938 buildDomainIndexSync(entries);939 await loadSettings();940 touchAutoLock();941 if (keyA) zeroKey(keyA);942 return true;943 }944945 if (keyA) zeroKey(keyA);946 if (keyB) zeroKey(keyB);947 return false;948}949950export function lock(): void {951 if (derivedKey) {952 zeroKey(derivedKey);953 derivedKey = null;954 }955 if (rootKey) {956 rootKey.fill(0);957 rootKey = null;958 }959960 entries = [];961 domainIndex = new Map();962 vaultMode = "real";963 duressConfigured = false;964 clearAutoLock();965}966967export function isUnlocked(): boolean {968 return derivedKey !== null;969}970971972973export async function unlockWithKey(974 masterKeyHex: string,975): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {976 try {977 const masterKeyBytes = unhex(masterKeyHex);978 const stored = await loadSlot(SLOT_A);979 if (!stored) return { ok: false, error: "invalid_key" };980 const payload = await tryDecryptSlot(stored, masterKeyBytes);981 if (!payload) return { ok: false, error: "invalid_key" };982983 derivedKey = masterKeyBytes;984 vaultMode = "real";985 entries = payload.entries;986 duressConfigured = payload.duressConfigured;987 buildDomainIndexSync(entries);988 await loadSettings();989 touchAutoLock();990 return { ok: true, entries };991 } catch {992 return { ok: false, error: "invalid_key" };993 }994}995996997998export function getDerivedKeyHex(): string {999 if (!derivedKey) throw new Error("vault_not_unlocked");1000 return hex(derivedKey);1001}10021003export function getEntries(): VaultEntry[] {1004 if (!derivedKey) throw new Error("vault locked");1005 return entries.filter((entry) => !entry.deletedAt);1006}10071008const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;10091010export function getIdentities(): SavedIdentity[] {1011 if (!derivedKey) throw new Error("vault locked");1012 const active = entries.filter((e) => !e.deletedAt && e.type === "login");10131014 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1015 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();10161017 for (const entry of active) {1018 const value = entry.username?.trim();1019 if (!value) continue;1020 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;1021 const existing = map.get(value);1022 const domain = extractDomain(entry.url ?? "") ?? "";1023 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;1024 if (!existing || modifiedAt > existing.lastUsed) {1025 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1026 } else {1027 map.set(value, { ...existing, count: existing.count + 1 });1028 }1029 }10301031 const toIdentity = (type: "email" | "username") =>1032 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1033 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1034 });10351036 return [1037 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1038 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1039 ];1040}10411042export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1043 if (!derivedKey) throw new Error("vault locked");10441045 const now = Date.now();1046 const added: VaultEntry[] = [];1047 for (const draft of drafts) {1048 try {1049 const sanitized = sanitizeEntryDraft(draft);1050 const nextEntry: VaultEntry = {1051 ...sanitized,1052 id: crypto.randomUUID(),1053 createdAt: now,1054 modifiedAt: now,1055 };1056 entries.push(nextEntry);1057 added.push(nextEntry);1058 } catch {1059 1060 }1061 }10621063 if (added.length > 0) {1064 await persistEntries();1065 }10661067 return added;1068}106910701071107210731074107510761077export async function restoreEntries(1078 incoming: VaultEntry[],1079 mode: "replace" | "merge"1080): Promise<{ added: number; updated: number; skipped: number }> {1081 if (!derivedKey) throw new Error("vault locked");10821083 1084 1085 1086 if (mode === "replace" && incoming.length === 0) {1087 throw new Error("refuse to replace with empty entry set");1088 }10891090 let added = 0;1091 let updated = 0;1092 let skipped = 0;10931094 if (mode === "replace") {1095 entries = [];1096 for (const entry of incoming) {1097 const normalized = sanitizeRestoredEntry(entry);1098 if (!normalized) {1099 skipped += 1;1100 continue;1101 }1102 entries.push(normalized);1103 added += 1;1104 }1105 } else {1106 const byId = new Map<string, VaultEntry>();1107 for (const e of entries) byId.set(e.id, e);11081109 for (const entry of incoming) {1110 const normalized = sanitizeRestoredEntry(entry);1111 if (!normalized) {1112 skipped += 1;1113 continue;1114 }1115 const existing = byId.get(normalized.id);1116 if (!existing) {1117 entries.push(normalized);1118 added += 1;1119 } else if (normalized.modifiedAt > existing.modifiedAt) {1120 const idx = entries.findIndex((e) => e.id === existing.id);1121 if (idx !== -1) {1122 entries[idx] = normalized;1123 updated += 1;1124 }1125 } else {1126 skipped += 1;1127 }1128 }1129 }11301131 await persistEntries("import");1132 return { added, updated, skipped };1133}1134113511361137113811391140function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1141 if (!entry || typeof entry !== "object") return null;1142 const title = typeof entry.title === "string" ? entry.title.trim() : "";1143 if (!title) return null;1144 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1145 const now = Date.now();1146 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1147 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;11481149 1150 1151 1152 const migrated: VaultEntry = migrateLegacyPasskey(1153 {1154 ...entry,1155 id,1156 title,1157 type: entry.type ?? "login",1158 createdAt,1159 modifiedAt,1160 },1161 entry1162 );11631164 1165 1166 if (1167 migrated.type === "login" &&1168 !migrated.passkey &&1169 typeof migrated.password !== "string"1170 ) {1171 return null;1172 }1173 if (migrated.passkey && !migrated.passkey.credentialId) {1174 return null;1175 }11761177 return migrated;1178}117911801181118211831184export async function clearVaultEntries(1185 confirmation: "REPLACE_ALL"1186): Promise<void> {1187 if (confirmation !== "REPLACE_ALL") {1188 throw new Error("clearVaultEntries requires explicit confirmation");1189 }1190 if (!derivedKey) throw new Error("vault locked");1191 for (const entry of entries) {1192 if (!entry.deletedAt) {1193 entry.deletedAt = Date.now();1194 entry.modifiedAt = Date.now();1195 }1196 }1197 await persistEntries();1198}11991200export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1201 if (!derivedKey) throw new Error("vault locked");12021203 const sanitized = sanitizeEntryDraft(entry);1204 const now = Date.now();1205 const nextEntry: VaultEntry = {1206 ...sanitized,1207 id: crypto.randomUUID(),1208 createdAt: now,1209 modifiedAt: now,1210 1211 1212 passwordChangedAt: sanitized.password ? now : undefined,1213 };12141215 entries.push(nextEntry);1216 await persistEntries();12171218 1219 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1220 checkBreach(nextEntry.password).then(async (count) => {1221 if (count > 0) {1222 const idx = entries.findIndex((e) => e.id === nextEntry.id);1223 if (idx !== -1) {1224 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1225 await persistEntries();1226 }1227 }1228 });1229 }12301231 return nextEntry;1232}12331234export async function updateEntry(1235 id: string,1236 patch: Partial<VaultEntry>1237): Promise<void> {1238 if (!derivedKey) throw new Error("vault locked");12391240 const idx = entries.findIndex((entry) => entry.id === id);1241 if (idx === -1 || entries[idx].deletedAt) {1242 throw new Error("entry not found");1243 }12441245 const current = entries[idx];1246 const sanitizedPatch = sanitizeEntryPatch(patch);1247 1248 1249 1250 const passwordChanged =1251 "password" in patch &&1252 typeof sanitizedPatch.password === "string" &&1253 sanitizedPatch.password !== current.password;1254 entries[idx] = {1255 ...current,1256 ...sanitizedPatch,1257 modifiedAt: Date.now(),1258 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1259 };12601261 1262 1263 1264 1265 if (1266 entries[idx].type === "login" &&1267 !entries[idx].password &&1268 !entries[idx].passkey1269 ) {1270 entries[idx] = current;1271 throw new Error("password or passkey required");1272 }12731274 1275 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1276 entries[idx] = current;1277 throw new Error("card number is required for card entries");1278 }12791280 await persistEntries();12811282 1283 1284 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1285 entries[idx] = { ...entries[idx], breach: undefined };1286 await persistEntries();1287 const password = entries[idx].password;1288 if (password) {1289 checkBreach(password).then(async (count) => {1290 const i = entries.findIndex((e) => e.id === id);1291 if (i !== -1 && count > 0) {1292 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1293 await persistEntries();1294 }1295 });1296 }1297 }1298}12991300export async function deleteEntry(id: string): Promise<void> {1301 if (!derivedKey) throw new Error("vault locked");13021303 const entry = entries.find((item) => item.id === id);1304 if (!entry || entry.deletedAt) {1305 throw new Error("entry not found");1306 }13071308 entry.deletedAt = Date.now();1309 entry.modifiedAt = Date.now();1310 await persistEntries();1311}13121313export function getDeletedEntries(): VaultEntry[] {1314 if (!derivedKey) return [];1315 return entries.filter((e) => !!e.deletedAt);1316}13171318export async function restoreEntry(id: string): Promise<void> {1319 if (!derivedKey) throw new Error("vault locked");1320 const entry = entries.find((e) => e.id === id && e.deletedAt);1321 if (!entry) throw new Error("deleted entry not found");1322 delete entry.deletedAt;1323 entry.modifiedAt = Date.now();1324 await persistEntries();1325 buildDomainIndexSync(entries);1326}13271328export async function purgeEntry(id: string): Promise<void> {1329 if (!derivedKey) throw new Error("vault locked");1330 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1331 if (idx === -1) throw new Error("deleted entry not found");1332 entries.splice(idx, 1);1333 await persistEntries();1334}133513361337export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1338 if (!derivedKey || retentionMs === 0) return 0;1339 const cutoff = Date.now() - retentionMs;1340 const before = entries.length;1341 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1342 const purged = before - entries.length;1343 if (purged > 0) await persistEntries();1344 return purged;1345}13461347export function queryDomain(domain: string): VaultEntry[] {1348 return domainIndex.get(domain) ?? [];1349}13501351export function getEntryById(id: string): VaultEntry | undefined {1352 return entries.find((entry) => entry.id === id && !entry.deletedAt);1353}13541355async function persistSlotA(1356 reason: VaultSnapshot["reason"] = "local_save",1357): Promise<StoredVault> {1358 if (!derivedKey) throw new Error("vault locked");13591360 const slotA = await loadSlot(SLOT_A);1361 if (!slotA) throw new Error("missing slot A");13621363 1364 1365 await takeVaultSnapshot(reason);13661367 const plaintext = encodePayload(entries, duressConfigured);1368 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);13691370 1371 const nextSlotA: StoredVault = {1372 ...slotA,1373 nonce,1374 ciphertext,1375 tag,1376 1377 };1378 await writeSlot(SLOT_A, nextSlotA);13791380 1381 1382 1383 if (!duressConfigured) {1384 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1385 }13861387 return nextSlotA;1388}13891390async function persistSlotB(): Promise<void> {1391 if (!derivedKey) throw new Error("vault locked");13921393 const slotB = await loadSlot(SLOT_B);1394 if (!slotB) throw new Error("missing slot B");13951396 1397 1398 const plaintext = encodePayload(entries, false);1399 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);14001401 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1402}14031404async function persistEntries(1405 reason: VaultSnapshot["reason"] = "local_save",1406): Promise<void> {1407 if (!derivedKey) throw new Error("vault locked");14081409 if (vaultMode === "decoy") {1410 await persistSlotB();1411 } else {1412 await persistSlotA(reason);1413 }1414 buildDomainIndexSync(entries);1415}14161417function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1418 if (!Array.isArray(values)) return undefined;1419 const seen = new Set<string>();1420 const cleaned: string[] = [];1421 for (const value of values) {1422 const trimmed = typeof value === "string" ? value.trim() : "";1423 if (!trimmed) continue;1424 const key = trimmed.toLowerCase();1425 if (seen.has(key)) continue;1426 seen.add(key);1427 cleaned.push(trimmed);1428 }1429 return cleaned.length > 0 ? cleaned : undefined;1430}14311432function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1433 const title = entry.title.trim();1434 1435 1436 1437 let type: VaultEntry["type"] = entry.type ?? "login";1438 if (type === "passkey") type = "login";14391440 if (!title) throw new Error("title is required");1441 1442 1443 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1444 throw new Error("password or passkey required");1445 }1446 if (entry.passkey) {1447 const p = entry.passkey;1448 if (1449 !p.rpId ||1450 !p.credentialId ||1451 !p.publicKeyCose ||1452 !p.userHandle ||1453 !p.userName ||1454 (p.algorithm !== -7 && p.algorithm !== -8)1455 ) {1456 throw new Error("invalid passkey credential");1457 }1458 }1459 if (type === "card") {1460 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1461 throw new Error("cardholder name and card number are required");1462 }1463 }14641465 return {1466 type,1467 title,1468 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1469 password: type === "login" ? entry.password : undefined,1470 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1471 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1472 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1473 notes: entry.notes?.trim() || undefined,1474 breach: type === "login" ? entry.breach : undefined,1475 canary: type === "login" ? entry.canary : undefined,1476 tags: entry.tags,1477 passkey: entry.passkey,1478 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1479 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1480 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1481 expYear: type === "card" ? entry.expYear?.trim() : undefined,1482 cvc: type === "card" ? entry.cvc?.trim() : undefined,1483 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1484 network: type === "card" ? entry.network : undefined,1485 last4: type === "card" ? entry.last4?.trim() : undefined,1486 };1487}14881489function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1490 const nextPatch: Partial<VaultEntry> = {};14911492 if ("type" in patch) {1493 nextPatch.type = patch.type;1494 }14951496 if ("title" in patch) {1497 const title = patch.title?.trim() ?? "";1498 if (!title) throw new Error("title is required");1499 nextPatch.title = title;1500 }15011502 if ("url" in patch) {1503 nextPatch.url = patch.url?.trim() ?? "";1504 }15051506 if ("additionalUrls" in patch) {1507 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1508 const seen = new Set<string>();1509 const cleaned: string[] = [];1510 for (const value of incoming) {1511 const trimmed = typeof value === "string" ? value.trim() : "";1512 if (!trimmed) continue;1513 const key = trimmed.toLowerCase();1514 if (seen.has(key)) continue;1515 seen.add(key);1516 cleaned.push(trimmed);1517 }1518 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1519 }15201521 if ("username" in patch) {1522 nextPatch.username = patch.username?.trim() ?? "";1523 }15241525 if ("email" in patch) {1526 nextPatch.email = patch.email?.trim() || undefined;1527 }15281529 if ("password" in patch) {1530 const password = patch.password ?? "";1531 1532 nextPatch.password = password || undefined;1533 }15341535 if ("totp" in patch) {1536 nextPatch.totp = patch.totp?.trim() || undefined;1537 }15381539 if ("notes" in patch) {1540 nextPatch.notes = patch.notes?.trim() || undefined;1541 }15421543 if ("breach" in patch) {1544 nextPatch.breach = patch.breach;1545 }15461547 if ("canary" in patch) {1548 nextPatch.canary = patch.canary;1549 }15501551 if ("passkey" in patch) {1552 1553 1554 1555 nextPatch.passkey = patch.passkey;1556 }15571558 if ("tags" in patch) {1559 nextPatch.tags = patch.tags;1560 }15611562 if ("cardholderName" in patch) {1563 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1564 }1565 if ("cardNumber" in patch) {1566 const n = patch.cardNumber ?? "";1567 nextPatch.cardNumber = n || undefined;1568 }1569 if ("expMonth" in patch) {1570 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1571 }1572 if ("expYear" in patch) {1573 nextPatch.expYear = patch.expYear?.trim() || undefined;1574 }1575 if ("cvc" in patch) {1576 nextPatch.cvc = patch.cvc?.trim() || undefined;1577 }1578 if ("cardNickname" in patch) {1579 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1580 }1581 if ("network" in patch) {1582 nextPatch.network = patch.network;1583 }1584 if ("last4" in patch) {1585 nextPatch.last4 = patch.last4?.trim() || undefined;1586 }15871588 return nextPatch;1589}15901591function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1592 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1593 const modifiedAt =1594 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;15951596 const base: VaultEntry = {1597 id: entry.id,1598 type: "type" in entry ? entry.type : "login",1599 title: entry.title,1600 username: entry.username,1601 password: entry.password,1602 url: entry.url,1603 totp: entry.totp,1604 notes: entry.notes,1605 breach: entry.breach,1606 canary: "canary" in entry ? entry.canary : undefined,1607 passkey: "passkey" in entry ? entry.passkey : undefined,1608 createdAt,1609 modifiedAt,1610 deletedAt: entry.deletedAt,1611 tags: "tags" in entry ? entry.tags : undefined,1612 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1613 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1614 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1615 expYear: "expYear" in entry ? entry.expYear : undefined,1616 cvc: "cvc" in entry ? entry.cvc : undefined,1617 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1618 network: "network" in entry ? entry.network : undefined,1619 last4: "last4" in entry ? entry.last4 : undefined,1620 };16211622 return migrateLegacyPasskey(base, entry);1623}162416251626162716281629163016311632function migrateLegacyPasskey(1633 base: VaultEntry,1634 raw: VaultEntry | LegacyVaultEntry1635): VaultEntry {1636 if (base.passkey) return base;1637 const flat = raw as Partial<VaultEntry>;1638 const isPasskeyType = base.type === "passkey";1639 const hasFlatPasskeyFields =1640 typeof flat.rpId === "string" &&1641 typeof flat.credentialId === "string" &&1642 typeof flat.publicKeyCose === "string" &&1643 typeof flat.userHandle === "string" &&1644 typeof flat.userName === "string";1645 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1646 if (!hasFlatPasskeyFields) return base;16471648 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1649 base.passkey = {1650 rpId: flat.rpId!,1651 rpName: flat.rpName ?? flat.rpId!,1652 credentialId: flat.credentialId!,1653 publicKeyCose: flat.publicKeyCose!,1654 privateKeyJwk: flat.privateKeyJwk,1655 userHandle: flat.userHandle!,1656 userName: flat.userName!,1657 userDisplayName: flat.userDisplayName ?? flat.userName!,1658 algorithm,1659 signCount: flat.signCount ?? 0,1660 aaguid: flat.aaguid ?? "",1661 backupEligible: flat.backupEligible === true,1662 backupState: flat.backupState === true,1663 lastUsedAt: flat.lastUsedAt,1664 browser: flat.browser,1665 transports: flat.transports,1666 createdAt: base.createdAt,1667 };1668 1669 1670 1671 if (isPasskeyType) base.type = "login";1672 if (!base.username) base.username = flat.userName;1673 if (!base.url) base.url = `https://${flat.rpId}`;1674 return base;1675}1676167716781679168016811682function entryDupeKey(e: VaultEntry): string {1683 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1684 1685 1686 1687 1688 if (e.passkey) {1689 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1690 }1691 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1692}169316941695export function countDuplicates(): number {1696 const best = new Map<string, VaultEntry>();1697 let dupeCount = 0;1698 for (const e of entries.filter((e) => !e.deletedAt)) {1699 const key = entryDupeKey(e);1700 const existing = best.get(key);1701 if (!existing) {1702 best.set(key, e);1703 } else {1704 dupeCount++;1705 1706 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1707 best.set(key, e);1708 }1709 }1710 }1711 return dupeCount;1712}171317141715171617171718export async function deduplicateEntries(): Promise<number> {1719 if (!derivedKey) throw new Error("vault locked");1720 1721 1722 if (vaultMode === "decoy") {1723 throw new Error("deduplicate disabled in decoy mode");1724 }17251726 const best = new Map<string, VaultEntry>();1727 for (const e of entries.filter((en) => !en.deletedAt)) {1728 const key = entryDupeKey(e);1729 const existing = best.get(key);1730 if (!existing) {1731 best.set(key, e);1732 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1733 best.set(key, e);1734 }1735 }17361737 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1738 const now = Date.now();1739 let removed = 0;1740 entries = entries.map((e) => {1741 if (!e.deletedAt && !keepIds.has(e.id)) {1742 removed++;1743 return { ...e, deletedAt: now };1744 }1745 return e;1746 });17471748 if (removed > 0) await persistEntries();1749 return removed;1750}175117521753175417551756175717581759const HISTORY_KEY = "peach_vault_history";1760const HISTORY_MAX = 10;17611762176317641765176617671768export async function takeVaultSnapshot(1769 reason: VaultSnapshot["reason"],1770): Promise<void> {1771 try {1772 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);1773 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];1774 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];17751776 1777 1778 1779 if (!slotA) return;17801781 const vault_a = JSON.stringify(slotA);1782 const vault_b = JSON.stringify(slotB ?? slotA);17831784 1785 1786 1787 let entryCount: number | null = null;1788 if (derivedKey && vaultMode === "real") {1789 try {1790 entryCount = entries.filter((e) => !e.deletedAt).length;1791 } catch {1792 entryCount = null;1793 }1794 }17951796 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1797 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)1798 ? existingRaw1799 : { snapshots: [] };18001801 1802 1803 1804 const latest = history.snapshots[0];1805 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {1806 return;1807 }18081809 const snapshot: VaultSnapshot = {1810 id: crypto.randomUUID(),1811 savedAt: Date.now(),1812 reason,1813 vault_a,1814 vault_b,1815 entryCount,1816 };18171818 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);1819 await chrome.storage.local.set({ [HISTORY_KEY]: history });1820 } catch {1821 1822 1823 }1824}182518261827export async function clearVaultHistory(): Promise<void> {1828 try {1829 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });1830 } catch {1831 1832 }1833}183418351836export async function getVaultHistory(): Promise<VaultSnapshot[]> {1837 const raw = await chrome.storage.local.get(HISTORY_KEY);1838 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1839 if (!history || !Array.isArray(history.snapshots)) return [];1840 return history.snapshots;1841}18421843184418451846184718481849export async function restoreVaultSnapshot(1850 snapshotId: string,1851): Promise<{ ok: true } | { ok: false; error: string }> {1852 const history = await getVaultHistory();1853 const snapshot = history.find((s) => s.id === snapshotId);1854 if (!snapshot) return { ok: false, error: "snapshot_not_found" };18551856 1857 1858 1859 await takeVaultSnapshot("restore");18601861 let restoredA: StoredVault;1862 let restoredB: StoredVault;1863 try {1864 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;1865 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;1866 } catch {1867 return { ok: false, error: "snapshot_corrupt" };1868 }18691870 await writeSlot(SLOT_A, restoredA);1871 await writeSlot(SLOT_B, restoredB);18721873 1874 1875 1876 lock();18771878 return { ok: true };1879}18801881188218831884188518861887export async function changePassword(1888 currentPassword: string,1889 newPassword: string1890): Promise<boolean> {1891 if (!derivedKey || vaultMode !== "real") return false;18921893 1894 const valid = await verifyMainPassword(currentPassword);1895 if (!valid) return false;18961897 const slotA = await loadSlot(SLOT_A);1898 if (!slotA) return false;18991900 1901 const newSalt = generateSalt();1902 const newKey = await deriveKey(newPassword, newSalt);19031904 1905 const plaintext = encodePayload(entries, duressConfigured);1906 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);19071908 const freshSlotA: StoredVault = {1909 ...slotA,1910 salt: hex(newSalt),1911 nonce,1912 ciphertext,1913 tag,1914 };1915 1916 1917 1918 await takeVaultSnapshot("local_save");1919 await writeSlot(SLOT_A, freshSlotA);19201921 1922 1923 1924 zeroKey(derivedKey);1925 derivedKey = newKey;19261927 1928 1929 const {1930 rawRootKey: freshRawKey,1931 recoverySalt: freshSalt,1932 wrappedRootKey: freshWrapped,1933 wrappedNonce: freshNonce,1934 wrappedTag: freshTag,1935 } = await generateAndWrapRootKey();1936 rootKey = unhex(freshRawKey);1937 const slotAfterRekey = await loadSlot(SLOT_A);1938 if (slotAfterRekey) {1939 await writeSlot(SLOT_A, {1940 ...slotAfterRekey,1941 recovery_salt: freshSalt,1942 recovery_wrapped_root_key: freshWrapped,1943 recovery_nonce: freshNonce,1944 recovery_tag: freshTag,1945 });1946 }19471948 1949 1950 1951 if (!duressConfigured) {1952 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1953 }19541955 return true;1956}