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}711712async function tryDecryptSlot(713 vault: StoredVault,714 key: Uint8Array715): Promise<DecryptedPayload | null> {716 try {717 const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);718 return decodePayload(plain);719 } catch {720 return null;721 }722}723724725726727728function buildDomainIndexSync(all: VaultEntry[]): void {729 domainIndex = new Map();730731 for (const entry of all) {732 if (entry.deletedAt) continue;733 if (entry.type === "note" || entry.type === "card") continue;734735 736 737 738 739 const domains = new Set<string>();740 const primary = extractDomain(entry.url ?? "");741 if (primary) domains.add(primary);742 for (const extra of entry.additionalUrls ?? []) {743 const d = extractDomain(extra);744 if (d) domains.add(d);745 }746 if (domains.size === 0) continue;747748 for (const domain of domains) {749 const bucket = domainIndex.get(domain) ?? [];750 bucket.push(entry);751 domainIndex.set(domain, bucket);752 }753 }754}755756export async function createVault(password: string): Promise<void> {757 await ensureMigrated();758759 const salt = generateSalt();760 derivedKey = await deriveKey(password, salt);761 entries = [];762 vaultMode = "real";763 duressConfigured = false;764 buildDomainIndexSync(entries);765766 767 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =768 await generateAndWrapRootKey();769 rootKey = unhex(rawRootKey);770771 const plaintext = encodePayload(entries, duressConfigured);772 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);773774 const slotA: StoredVault = {775 version: 1,776 salt: hex(salt),777 nonce,778 ciphertext,779 tag,780 recovery_salt: recoverySalt,781 recovery_wrapped_root_key: wrappedRootKey,782 recovery_nonce: wrappedNonce,783 recovery_tag: wrappedTag,784 };785 await writeSlot(SLOT_A, slotA);786787 788 789 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));790791 touchAutoLock();792}793794export async function hasVault(): Promise<boolean> {795 await ensureMigrated();796 return (await loadSlot(SLOT_A)) !== null;797}798799800801802803804export async function isDuressEnabled(): Promise<boolean> {805 return vaultMode === "real" && duressConfigured;806}807808export async function hasDecoyVault(): Promise<boolean> {809 return isDuressEnabled();810}811812export async function setupDuress(duressPassword: string): Promise<void> {813 if (!derivedKey || vaultMode !== "real") {814 throw new Error("vault must be unlocked in real mode");815 }816 await ensureMigrated();817818 819 820 821 const slotA = await loadSlot(SLOT_A);822 if (!slotA) throw new Error("missing slot A");823824 const decoySalt = generateSalt();825 const decoyKey = await deriveKey(duressPassword, decoySalt);826 try {827 828 829 const targetBytes = slotA.ciphertext.length / 2;830 let plaintext = encodePayload([], false);831 const encoded = new TextEncoder().encode(plaintext);832 if (encoded.length < targetBytes) {833 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);834 }835 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);836 await writeSlot(SLOT_B, {837 version: 1,838 salt: hex(decoySalt),839 nonce,840 ciphertext,841 tag,842 });843 } finally {844 zeroKey(decoyKey);845 }846847 848 849 duressConfigured = true;850 await persistSlotA();851}852853export async function disableDuress(): Promise<void> {854 if (!derivedKey || vaultMode !== "real") {855 throw new Error("vault must be unlocked in real mode");856 }857 await ensureMigrated();858859 860 861 862 const slotA = await loadSlot(SLOT_A);863 if (slotA) {864 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));865 }866867 duressConfigured = false;868 await persistSlotA();869}870871export async function verifyMainPassword(password: string): Promise<boolean> {872 await ensureMigrated();873 const slotA = await loadSlot(SLOT_A);874 if (!slotA) return false;875 const key = await deriveKey(password, unhex(slotA.salt));876 try {877 return (await tryDecryptSlot(slotA, key)) !== null;878 } finally {879 zeroKey(key);880 }881}882883export async function unlock(password: string): Promise<boolean> {884 await ensureMigrated();885886 887 888 889 890 891 const [slotA, slotB] = await Promise.all([892 loadSlot(SLOT_A),893 loadSlot(SLOT_B),894 ]);895896 897 const [keyA, keyB] = await Promise.all([898 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),899 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),900 ]);901902 903 const [payloadA, payloadB] = await Promise.all([904 keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),905 keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),906 ]);907908 if (payloadA && keyA) {909 derivedKey = keyA;910 vaultMode = "real";911 entries = payloadA.entries;912 duressConfigured = payloadA.duressConfigured;913 buildDomainIndexSync(entries);914 await loadSettings();915 touchAutoLock();916 if (keyB) zeroKey(keyB);917918 919 if (slotA) {920 const needsRecoverySetup =921 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;922 if (!needsRecoverySetup) {923 rootKey = await deriveRootKeyFromDerivedKey(924 keyA,925 slotA.recovery_salt!926 );927 }928 }929930 931 932 933 if (payloadA.migratedFromLegacy) {934 await persistSlotA();935 }936 return true;937 }938939 if (payloadB && keyB) {940 derivedKey = keyB;941 vaultMode = "decoy";942 entries = payloadB.entries;943 duressConfigured = false;944 buildDomainIndexSync(entries);945 await loadSettings();946 touchAutoLock();947 if (keyA) zeroKey(keyA);948 return true;949 }950951 if (keyA) zeroKey(keyA);952 if (keyB) zeroKey(keyB);953 return false;954}955956export function lock(): void {957 if (derivedKey) {958 zeroKey(derivedKey);959 derivedKey = null;960 }961 if (rootKey) {962 rootKey.fill(0);963 rootKey = null;964 }965966 entries = [];967 domainIndex = new Map();968 vaultMode = "real";969 duressConfigured = false;970 clearAutoLock();971}972973export function isUnlocked(): boolean {974 return derivedKey !== null;975}976977978979export async function unlockWithKey(980 masterKeyHex: string,981): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {982 try {983 const masterKeyBytes = unhex(masterKeyHex);984 const stored = await loadSlot(SLOT_A);985 if (!stored) return { ok: false, error: "invalid_key" };986 const payload = await tryDecryptSlot(stored, masterKeyBytes);987 if (!payload) return { ok: false, error: "invalid_key" };988989 derivedKey = masterKeyBytes;990 vaultMode = "real";991 entries = payload.entries;992 duressConfigured = payload.duressConfigured;993 buildDomainIndexSync(entries);994 await loadSettings();995 touchAutoLock();996 return { ok: true, entries };997 } catch {998 return { ok: false, error: "invalid_key" };999 }1000}1001100210031004export function getDerivedKeyHex(): string {1005 if (!derivedKey) throw new Error("vault_not_unlocked");1006 return hex(derivedKey);1007}10081009export function getEntries(): VaultEntry[] {1010 if (!derivedKey) throw new Error("vault locked");1011 return entries.filter((entry) => !entry.deletedAt);1012}10131014const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;10151016export function getIdentities(): SavedIdentity[] {1017 if (!derivedKey) throw new Error("vault locked");1018 const active = entries.filter((e) => !e.deletedAt && e.type === "login");10191020 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1021 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();10221023 for (const entry of active) {1024 const value = entry.username?.trim();1025 if (!value) continue;1026 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;1027 const existing = map.get(value);1028 const domain = extractDomain(entry.url ?? "") ?? "";1029 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;1030 if (!existing || modifiedAt > existing.lastUsed) {1031 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1032 } else {1033 map.set(value, { ...existing, count: existing.count + 1 });1034 }1035 }10361037 const toIdentity = (type: "email" | "username") =>1038 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1039 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1040 });10411042 return [1043 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1044 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1045 ];1046}10471048export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1049 if (!derivedKey) throw new Error("vault locked");10501051 const now = Date.now();1052 const added: VaultEntry[] = [];1053 for (const draft of drafts) {1054 try {1055 const sanitized = sanitizeEntryDraft(draft);1056 const nextEntry: VaultEntry = {1057 ...sanitized,1058 id: crypto.randomUUID(),1059 createdAt: now,1060 modifiedAt: now,1061 };1062 entries.push(nextEntry);1063 added.push(nextEntry);1064 } catch {1065 1066 }1067 }10681069 if (added.length > 0) {1070 await persistEntries();1071 }10721073 return added;1074}107510761077107810791080108110821083export async function restoreEntries(1084 incoming: VaultEntry[],1085 mode: "replace" | "merge"1086): Promise<{ added: number; updated: number; skipped: number }> {1087 if (!derivedKey) throw new Error("vault locked");10881089 1090 1091 1092 if (mode === "replace" && incoming.length === 0) {1093 throw new Error("refuse to replace with empty entry set");1094 }10951096 let added = 0;1097 let updated = 0;1098 let skipped = 0;10991100 if (mode === "replace") {1101 entries = [];1102 for (const entry of incoming) {1103 const normalized = sanitizeRestoredEntry(entry);1104 if (!normalized) {1105 skipped += 1;1106 continue;1107 }1108 entries.push(normalized);1109 added += 1;1110 }1111 } else {1112 const byId = new Map<string, VaultEntry>();1113 for (const e of entries) byId.set(e.id, e);11141115 for (const entry of incoming) {1116 const normalized = sanitizeRestoredEntry(entry);1117 if (!normalized) {1118 skipped += 1;1119 continue;1120 }1121 const existing = byId.get(normalized.id);1122 if (!existing) {1123 entries.push(normalized);1124 added += 1;1125 } else if (normalized.modifiedAt > existing.modifiedAt) {1126 const idx = entries.findIndex((e) => e.id === existing.id);1127 if (idx !== -1) {1128 entries[idx] = normalized;1129 updated += 1;1130 }1131 } else {1132 skipped += 1;1133 }1134 }1135 }11361137 await persistEntries("import");1138 return { added, updated, skipped };1139}1140114111421143114411451146function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1147 if (!entry || typeof entry !== "object") return null;1148 const title = typeof entry.title === "string" ? entry.title.trim() : "";1149 if (!title) return null;1150 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1151 const now = Date.now();1152 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1153 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;11541155 1156 1157 1158 const migrated: VaultEntry = migrateLegacyPasskey(1159 {1160 ...entry,1161 id,1162 title,1163 type: entry.type ?? "login",1164 createdAt,1165 modifiedAt,1166 },1167 entry1168 );11691170 1171 1172 if (1173 migrated.type === "login" &&1174 !migrated.passkey &&1175 typeof migrated.password !== "string"1176 ) {1177 return null;1178 }1179 if (migrated.passkey && !migrated.passkey.credentialId) {1180 return null;1181 }11821183 return migrated;1184}118511861187118811891190export async function clearVaultEntries(1191 confirmation: "REPLACE_ALL"1192): Promise<void> {1193 if (confirmation !== "REPLACE_ALL") {1194 throw new Error("clearVaultEntries requires explicit confirmation");1195 }1196 if (!derivedKey) throw new Error("vault locked");1197 for (const entry of entries) {1198 if (!entry.deletedAt) {1199 entry.deletedAt = Date.now();1200 entry.modifiedAt = Date.now();1201 }1202 }1203 await persistEntries();1204}12051206export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1207 if (!derivedKey) throw new Error("vault locked");12081209 const sanitized = sanitizeEntryDraft(entry);1210 const now = Date.now();1211 const nextEntry: VaultEntry = {1212 ...sanitized,1213 id: crypto.randomUUID(),1214 createdAt: now,1215 modifiedAt: now,1216 1217 1218 passwordChangedAt: sanitized.password ? now : undefined,1219 };12201221 entries.push(nextEntry);1222 await persistEntries();12231224 1225 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1226 checkBreach(nextEntry.password).then(async (count) => {1227 if (count > 0) {1228 const idx = entries.findIndex((e) => e.id === nextEntry.id);1229 if (idx !== -1) {1230 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1231 await persistEntries();1232 }1233 }1234 });1235 }12361237 return nextEntry;1238}12391240export async function updateEntry(1241 id: string,1242 patch: Partial<VaultEntry>1243): Promise<void> {1244 if (!derivedKey) throw new Error("vault locked");12451246 const idx = entries.findIndex((entry) => entry.id === id);1247 if (idx === -1 || entries[idx].deletedAt) {1248 throw new Error("entry not found");1249 }12501251 const current = entries[idx];1252 const sanitizedPatch = sanitizeEntryPatch(patch);1253 1254 1255 1256 const passwordChanged =1257 "password" in patch &&1258 typeof sanitizedPatch.password === "string" &&1259 sanitizedPatch.password !== current.password;1260 entries[idx] = {1261 ...current,1262 ...sanitizedPatch,1263 modifiedAt: Date.now(),1264 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1265 };12661267 1268 1269 1270 1271 if (1272 entries[idx].type === "login" &&1273 !entries[idx].password &&1274 !entries[idx].passkey1275 ) {1276 entries[idx] = current;1277 throw new Error("password or passkey required");1278 }12791280 1281 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1282 entries[idx] = current;1283 throw new Error("card number is required for card entries");1284 }12851286 await persistEntries();12871288 1289 1290 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1291 entries[idx] = { ...entries[idx], breach: undefined };1292 await persistEntries();1293 const password = entries[idx].password;1294 if (password) {1295 checkBreach(password).then(async (count) => {1296 const i = entries.findIndex((e) => e.id === id);1297 if (i !== -1 && count > 0) {1298 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1299 await persistEntries();1300 }1301 });1302 }1303 }1304}13051306export async function deleteEntry(id: string): Promise<void> {1307 if (!derivedKey) throw new Error("vault locked");13081309 const entry = entries.find((item) => item.id === id);1310 if (!entry || entry.deletedAt) {1311 throw new Error("entry not found");1312 }13131314 entry.deletedAt = Date.now();1315 entry.modifiedAt = Date.now();1316 await persistEntries();1317}13181319export function getDeletedEntries(): VaultEntry[] {1320 if (!derivedKey) return [];1321 return entries.filter((e) => !!e.deletedAt);1322}13231324export async function restoreEntry(id: string): Promise<void> {1325 if (!derivedKey) throw new Error("vault locked");1326 const entry = entries.find((e) => e.id === id && e.deletedAt);1327 if (!entry) throw new Error("deleted entry not found");1328 delete entry.deletedAt;1329 entry.modifiedAt = Date.now();1330 await persistEntries();1331 buildDomainIndexSync(entries);1332}13331334export async function purgeEntry(id: string): Promise<void> {1335 if (!derivedKey) throw new Error("vault locked");1336 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1337 if (idx === -1) throw new Error("deleted entry not found");1338 entries.splice(idx, 1);1339 await persistEntries();1340}134113421343export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1344 if (!derivedKey || retentionMs === 0) return 0;1345 const cutoff = Date.now() - retentionMs;1346 const before = entries.length;1347 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1348 const purged = before - entries.length;1349 if (purged > 0) await persistEntries();1350 return purged;1351}13521353export function queryDomain(domain: string): VaultEntry[] {1354 return domainIndex.get(domain) ?? [];1355}13561357export function getEntryById(id: string): VaultEntry | undefined {1358 return entries.find((entry) => entry.id === id && !entry.deletedAt);1359}13601361async function persistSlotA(1362 reason: VaultSnapshot["reason"] = "local_save",1363): Promise<StoredVault> {1364 if (!derivedKey) throw new Error("vault locked");13651366 const slotA = await loadSlot(SLOT_A);1367 if (!slotA) throw new Error("missing slot A");13681369 1370 1371 await takeVaultSnapshot(reason);13721373 const plaintext = encodePayload(entries, duressConfigured);1374 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);13751376 1377 const nextSlotA: StoredVault = {1378 ...slotA,1379 nonce,1380 ciphertext,1381 tag,1382 1383 };1384 await writeSlot(SLOT_A, nextSlotA);13851386 1387 1388 1389 if (!duressConfigured) {1390 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1391 }13921393 return nextSlotA;1394}13951396async function persistSlotB(): Promise<void> {1397 if (!derivedKey) throw new Error("vault locked");13981399 const slotB = await loadSlot(SLOT_B);1400 if (!slotB) throw new Error("missing slot B");14011402 1403 1404 const plaintext = encodePayload(entries, false);1405 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);14061407 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1408}14091410async function persistEntries(1411 reason: VaultSnapshot["reason"] = "local_save",1412): Promise<void> {1413 if (!derivedKey) throw new Error("vault locked");14141415 if (vaultMode === "decoy") {1416 await persistSlotB();1417 } else {1418 await persistSlotA(reason);1419 }1420 buildDomainIndexSync(entries);1421}14221423function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1424 if (!Array.isArray(values)) return undefined;1425 const seen = new Set<string>();1426 const cleaned: string[] = [];1427 for (const value of values) {1428 const trimmed = typeof value === "string" ? value.trim() : "";1429 if (!trimmed) continue;1430 const key = trimmed.toLowerCase();1431 if (seen.has(key)) continue;1432 seen.add(key);1433 cleaned.push(trimmed);1434 }1435 return cleaned.length > 0 ? cleaned : undefined;1436}14371438function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1439 const title = entry.title.trim();1440 1441 1442 1443 let type: VaultEntry["type"] = entry.type ?? "login";1444 if (type === "passkey") type = "login";14451446 if (!title) throw new Error("title is required");1447 1448 1449 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1450 throw new Error("password or passkey required");1451 }1452 if (entry.passkey) {1453 const p = entry.passkey;1454 if (1455 !p.rpId ||1456 !p.credentialId ||1457 !p.publicKeyCose ||1458 !p.userHandle ||1459 !p.userName ||1460 (p.algorithm !== -7 && p.algorithm !== -8)1461 ) {1462 throw new Error("invalid passkey credential");1463 }1464 }1465 if (type === "card") {1466 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1467 throw new Error("cardholder name and card number are required");1468 }1469 }14701471 return {1472 type,1473 title,1474 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1475 password: type === "login" ? entry.password : undefined,1476 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1477 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1478 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1479 notes: entry.notes?.trim() || undefined,1480 breach: type === "login" ? entry.breach : undefined,1481 canary: type === "login" ? entry.canary : undefined,1482 tags: entry.tags,1483 passkey: entry.passkey,1484 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1485 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1486 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1487 expYear: type === "card" ? entry.expYear?.trim() : undefined,1488 cvc: type === "card" ? entry.cvc?.trim() : undefined,1489 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1490 network: type === "card" ? entry.network : undefined,1491 last4: type === "card" ? entry.last4?.trim() : undefined,1492 };1493}14941495function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1496 const nextPatch: Partial<VaultEntry> = {};14971498 if ("type" in patch) {1499 nextPatch.type = patch.type;1500 }15011502 if ("title" in patch) {1503 const title = patch.title?.trim() ?? "";1504 if (!title) throw new Error("title is required");1505 nextPatch.title = title;1506 }15071508 if ("url" in patch) {1509 nextPatch.url = patch.url?.trim() ?? "";1510 }15111512 if ("additionalUrls" in patch) {1513 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1514 const seen = new Set<string>();1515 const cleaned: string[] = [];1516 for (const value of incoming) {1517 const trimmed = typeof value === "string" ? value.trim() : "";1518 if (!trimmed) continue;1519 const key = trimmed.toLowerCase();1520 if (seen.has(key)) continue;1521 seen.add(key);1522 cleaned.push(trimmed);1523 }1524 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1525 }15261527 if ("username" in patch) {1528 nextPatch.username = patch.username?.trim() ?? "";1529 }15301531 if ("email" in patch) {1532 nextPatch.email = patch.email?.trim() || undefined;1533 }15341535 if ("password" in patch) {1536 const password = patch.password ?? "";1537 1538 nextPatch.password = password || undefined;1539 }15401541 if ("totp" in patch) {1542 nextPatch.totp = patch.totp?.trim() || undefined;1543 }15441545 if ("notes" in patch) {1546 nextPatch.notes = patch.notes?.trim() || undefined;1547 }15481549 if ("breach" in patch) {1550 nextPatch.breach = patch.breach;1551 }15521553 if ("canary" in patch) {1554 nextPatch.canary = patch.canary;1555 }15561557 if ("passkey" in patch) {1558 1559 1560 1561 nextPatch.passkey = patch.passkey;1562 }15631564 if ("tags" in patch) {1565 nextPatch.tags = patch.tags;1566 }15671568 if ("cardholderName" in patch) {1569 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1570 }1571 if ("cardNumber" in patch) {1572 const n = patch.cardNumber ?? "";1573 nextPatch.cardNumber = n || undefined;1574 }1575 if ("expMonth" in patch) {1576 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1577 }1578 if ("expYear" in patch) {1579 nextPatch.expYear = patch.expYear?.trim() || undefined;1580 }1581 if ("cvc" in patch) {1582 nextPatch.cvc = patch.cvc?.trim() || undefined;1583 }1584 if ("cardNickname" in patch) {1585 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1586 }1587 if ("network" in patch) {1588 nextPatch.network = patch.network;1589 }1590 if ("last4" in patch) {1591 nextPatch.last4 = patch.last4?.trim() || undefined;1592 }15931594 return nextPatch;1595}15961597function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1598 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1599 const modifiedAt =1600 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;16011602 const base: VaultEntry = {1603 id: entry.id,1604 type: "type" in entry ? entry.type : "login",1605 title: entry.title,1606 username: entry.username,1607 password: entry.password,1608 url: entry.url,1609 totp: entry.totp,1610 notes: entry.notes,1611 breach: entry.breach,1612 canary: "canary" in entry ? entry.canary : undefined,1613 passkey: "passkey" in entry ? entry.passkey : undefined,1614 createdAt,1615 modifiedAt,1616 deletedAt: entry.deletedAt,1617 tags: "tags" in entry ? entry.tags : undefined,1618 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1619 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1620 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1621 expYear: "expYear" in entry ? entry.expYear : undefined,1622 cvc: "cvc" in entry ? entry.cvc : undefined,1623 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1624 network: "network" in entry ? entry.network : undefined,1625 last4: "last4" in entry ? entry.last4 : undefined,1626 };16271628 return migrateLegacyPasskey(base, entry);1629}163016311632163316341635163616371638function migrateLegacyPasskey(1639 base: VaultEntry,1640 raw: VaultEntry | LegacyVaultEntry1641): VaultEntry {1642 if (base.passkey) return base;1643 const flat = raw as Partial<VaultEntry>;1644 const isPasskeyType = base.type === "passkey";1645 const hasFlatPasskeyFields =1646 typeof flat.rpId === "string" &&1647 typeof flat.credentialId === "string" &&1648 typeof flat.publicKeyCose === "string" &&1649 typeof flat.userHandle === "string" &&1650 typeof flat.userName === "string";1651 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1652 if (!hasFlatPasskeyFields) return base;16531654 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1655 base.passkey = {1656 rpId: flat.rpId!,1657 rpName: flat.rpName ?? flat.rpId!,1658 credentialId: flat.credentialId!,1659 publicKeyCose: flat.publicKeyCose!,1660 privateKeyJwk: flat.privateKeyJwk,1661 userHandle: flat.userHandle!,1662 userName: flat.userName!,1663 userDisplayName: flat.userDisplayName ?? flat.userName!,1664 algorithm,1665 signCount: flat.signCount ?? 0,1666 aaguid: flat.aaguid ?? "",1667 backupEligible: flat.backupEligible === true,1668 backupState: flat.backupState === true,1669 lastUsedAt: flat.lastUsedAt,1670 browser: flat.browser,1671 transports: flat.transports,1672 createdAt: base.createdAt,1673 };1674 1675 1676 1677 if (isPasskeyType) base.type = "login";1678 if (!base.username) base.username = flat.userName;1679 if (!base.url) base.url = `https://${flat.rpId}`;1680 return base;1681}1682168316841685168616871688function entryDupeKey(e: VaultEntry): string {1689 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1690 1691 1692 1693 1694 if (e.passkey) {1695 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1696 }1697 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1698}169917001701export function countDuplicates(): number {1702 const best = new Map<string, VaultEntry>();1703 let dupeCount = 0;1704 for (const e of entries.filter((e) => !e.deletedAt)) {1705 const key = entryDupeKey(e);1706 const existing = best.get(key);1707 if (!existing) {1708 best.set(key, e);1709 } else {1710 dupeCount++;1711 1712 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1713 best.set(key, e);1714 }1715 }1716 }1717 return dupeCount;1718}171917201721172217231724export async function deduplicateEntries(): Promise<number> {1725 if (!derivedKey) throw new Error("vault locked");1726 1727 1728 if (vaultMode === "decoy") {1729 throw new Error("deduplicate disabled in decoy mode");1730 }17311732 const best = new Map<string, VaultEntry>();1733 for (const e of entries.filter((en) => !en.deletedAt)) {1734 const key = entryDupeKey(e);1735 const existing = best.get(key);1736 if (!existing) {1737 best.set(key, e);1738 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1739 best.set(key, e);1740 }1741 }17421743 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1744 const now = Date.now();1745 let removed = 0;1746 entries = entries.map((e) => {1747 if (!e.deletedAt && !keepIds.has(e.id)) {1748 removed++;1749 return { ...e, deletedAt: now };1750 }1751 return e;1752 });17531754 if (removed > 0) await persistEntries();1755 return removed;1756}175717581759176017611762176317641765const HISTORY_KEY = "peach_vault_history";1766const HISTORY_MAX = 10;17671768176917701771177217731774export async function takeVaultSnapshot(1775 reason: VaultSnapshot["reason"],1776): Promise<void> {1777 try {1778 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);1779 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];1780 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];17811782 1783 1784 1785 if (!slotA) return;17861787 const vault_a = JSON.stringify(slotA);1788 const vault_b = JSON.stringify(slotB ?? slotA);17891790 1791 1792 1793 let entryCount: number | null = null;1794 if (derivedKey && vaultMode === "real") {1795 try {1796 entryCount = entries.filter((e) => !e.deletedAt).length;1797 } catch {1798 entryCount = null;1799 }1800 }18011802 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1803 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)1804 ? existingRaw1805 : { snapshots: [] };18061807 1808 1809 1810 const latest = history.snapshots[0];1811 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {1812 return;1813 }18141815 const snapshot: VaultSnapshot = {1816 id: crypto.randomUUID(),1817 savedAt: Date.now(),1818 reason,1819 vault_a,1820 vault_b,1821 entryCount,1822 };18231824 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);1825 await chrome.storage.local.set({ [HISTORY_KEY]: history });1826 } catch {1827 1828 1829 }1830}183118321833export async function clearVaultHistory(): Promise<void> {1834 try {1835 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });1836 } catch {1837 1838 }1839}184018411842export async function getVaultHistory(): Promise<VaultSnapshot[]> {1843 const raw = await chrome.storage.local.get(HISTORY_KEY);1844 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1845 if (!history || !Array.isArray(history.snapshots)) return [];1846 return history.snapshots;1847}18481849185018511852185318541855export async function restoreVaultSnapshot(1856 snapshotId: string,1857): Promise<{ ok: true } | { ok: false; error: string }> {1858 const history = await getVaultHistory();1859 const snapshot = history.find((s) => s.id === snapshotId);1860 if (!snapshot) return { ok: false, error: "snapshot_not_found" };18611862 1863 1864 1865 await takeVaultSnapshot("restore");18661867 let restoredA: StoredVault;1868 let restoredB: StoredVault;1869 try {1870 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;1871 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;1872 } catch {1873 return { ok: false, error: "snapshot_corrupt" };1874 }18751876 await writeSlot(SLOT_A, restoredA);1877 await writeSlot(SLOT_B, restoredB);18781879 1880 1881 1882 lock();18831884 return { ok: true };1885}18861887188818891890189118921893export async function changePassword(1894 currentPassword: string,1895 newPassword: string1896): Promise<boolean> {1897 if (!derivedKey || vaultMode !== "real") return false;18981899 1900 const valid = await verifyMainPassword(currentPassword);1901 if (!valid) return false;19021903 const slotA = await loadSlot(SLOT_A);1904 if (!slotA) return false;19051906 1907 const newSalt = generateSalt();1908 const newKey = await deriveKey(newPassword, newSalt);19091910 1911 const plaintext = encodePayload(entries, duressConfigured);1912 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);19131914 const freshSlotA: StoredVault = {1915 ...slotA,1916 salt: hex(newSalt),1917 nonce,1918 ciphertext,1919 tag,1920 };1921 1922 1923 1924 await takeVaultSnapshot("local_save");1925 await writeSlot(SLOT_A, freshSlotA);19261927 1928 1929 1930 zeroKey(derivedKey);1931 derivedKey = newKey;19321933 1934 1935 const {1936 rawRootKey: freshRawKey,1937 recoverySalt: freshSalt,1938 wrappedRootKey: freshWrapped,1939 wrappedNonce: freshNonce,1940 wrappedTag: freshTag,1941 } = await generateAndWrapRootKey();1942 rootKey = unhex(freshRawKey);1943 const slotAfterRekey = await loadSlot(SLOT_A);1944 if (slotAfterRekey) {1945 await writeSlot(SLOT_A, {1946 ...slotAfterRekey,1947 recovery_salt: freshSalt,1948 recovery_wrapped_root_key: freshWrapped,1949 recovery_nonce: freshNonce,1950 recovery_tag: freshTag,1951 });1952 }19531954 1955 1956 1957 if (!duressConfigured) {1958 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1959 }19601961 return true;1962}