123456789101112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import type {15 AccentColorName,16 LegacyVaultEntry,17 ManualSummonRecord,18 SavedIdentity,19 StoredVault,20 VaultEntry,21 VaultHistory,22 VaultSnapshot,23} from "../shared/types";24import { decrypt, encrypt, zeroKey } from "./aes";25import { clearAutoLock, touchAutoLock } from "./alarms";2627type EntryDraft = Omit<28 VaultEntry,29 "id" | "createdAt" | "modifiedAt" | "deletedAt"30>;313233343536const SLOT_A = "peach_vault_a";37const SLOT_B = "peach_vault_b";383940const LEGACY_MAIN = "peach_vault";41const LEGACY_DECOY = "peach_decoy_vault";42const LEGACY_DURESS_FLAG = "peach_duress";43444546const PAD_BLOCK = 1024;4748let derivedKey: Uint8Array | null = null;4950515253let rootKey: Uint8Array | null = null;54let entries: VaultEntry[] = [];55let domainIndex: Map<string, VaultEntry[]> = new Map();56let vaultMode: "real" | "decoy" = "real";57585960let duressConfigured = false;6162let migrationRan = false;636465666768export type DisplayFont = "cormorant-italic" | "cormorant" | "inter" | "system";69export type TextSize = "small" | "medium" | "large";7071export interface VaultSettings {72 accentColor?: AccentColorName;73 theme?: "dark" | "light";74 autoLockMinutes?: number;75 clipboardClearSeconds?: number;76 lockOnClose?: boolean;77 breachMonitoring?: boolean;78 passkeyProviderEnabled?: boolean;79 trashRetentionDays?: number;80 manualSummonDomains?: Record<string, ManualSummonRecord>;81 declinedCardFingerprints?: string[];82 8384858687 displayFont?: DisplayFont;88 8990919293 textSize?: TextSize;94 9596979899 reduceMotion?: boolean;100 101102103104105106107 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;108}109110let settings: VaultSettings = {};111112export function getSettings(): VaultSettings {113 return settings;114}115116export async function ensureSettingsLoaded(): Promise<VaultSettings> {117 await loadSettings();118 return settings;119}120121export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {122 await loadSettings();123 settings = { ...settings, ...patch };124 await persistSettings();125}126127async function persistSettings(): Promise<void> {128 try {129 await chrome.storage.local.set({ peach_settings: settings });130 } catch (err) {131 throw mapStorageError(err);132 }133}134135function mapStorageError(err: unknown): Error {136 const message = err instanceof Error ? err.message : String(err);137 if (/QUOTA_BYTES|quota/i.test(message)) {138 return new Error("STORAGE_QUOTA_EXCEEDED");139 }140 return err instanceof Error ? err : new Error(message);141}142143async function loadSettings(): Promise<void> {144 const result = await chrome.storage.local.get("peach_settings");145 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};146}147148149150151152export async function checkBreach(password: string): Promise<number> {153 const encoder = new TextEncoder();154 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));155 const hashHex = Array.from(new Uint8Array(hashBuffer))156 .map((b) => b.toString(16).padStart(2, "0"))157 .join("")158 .toUpperCase();159160 const prefix = hashHex.slice(0, 5);161 const suffix = hashHex.slice(5);162163 try {164 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);165 if (!res.ok) return 0;166 const text = await res.text();167 const match = text.split("\r\n").find((l) => l.startsWith(suffix));168 return match ? parseInt(match.split(":")[1], 10) : 0;169 } catch {170 return 0; 171 }172}173174export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {175 const fingerprint = await cardFingerprint(last4, expMonth, expYear);176 await loadSettings();177 settings = {178 ...settings,179 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],180 };181 await persistSettings();182}183184export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {185 await loadSettings();186 const fingerprint = await cardFingerprint(last4, expMonth, expYear);187 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);188}189190async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {191 const encoder = new TextEncoder();192 const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));193 return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");194}195196function hex(buf: Uint8Array): string {197 return Array.from(buf)198 .map((b) => b.toString(16).padStart(2, "0"))199 .join("");200}201202function unhex(s: string): Uint8Array {203 const out = new Uint8Array(s.length / 2);204 for (let i = 0; i < s.length; i += 2) {205 out[i / 2] = parseInt(s.slice(i, i + 2), 16);206 }207 return out;208}209210211212213214215216217218219220221async function generateAndWrapRootKey(): Promise<{222 rawRootKey: string; 223 recoverySalt: string;224 wrappedRootKey: string;225 wrappedNonce: string;226 wrappedTag: string;227}> {228 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));229230 231 const { rootKeyToWords, wordsToRootKeyMaterial, deriveRecoveryKey } =232 await import("../shared/recovery-words");233 const words = await rootKeyToWords(rootKeyBytes);234 const wordMaterial = await wordsToRootKeyMaterial(words);235 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));236 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);237238 239 240 const nonce = crypto.getRandomValues(new Uint8Array(12));241 const encoded = new TextEncoder().encode(hex(rootKeyBytes));242 const raw = await crypto.subtle.encrypt(243 {244 name: "AES-GCM",245 iv: nonce,246 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,247 tagLength: 128,248 },249 recoveryKey,250 encoded251 );252 253 const rawU8 = new Uint8Array(raw);254 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));255 const tag = hex(rawU8.slice(rawU8.length - 16));256257 return {258 rawRootKey: hex(rootKeyBytes),259 recoverySalt: hex(recoverySalt),260 wrappedRootKey: ciphertext,261 wrappedNonce: hex(nonce),262 wrappedTag: tag,263 };264}265266267268269270271export async function unwrapRootKeyForRecovery(272 wrappedRootKey: string,273 wrappedNonce: string,274 wrappedTag: string,275 recoverySaltHex: string,276 words: string[]277): Promise<void> {278 const { wordsToRootKeyMaterial, deriveRecoveryKey } = await import(279 "../shared/recovery-words"280 );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 const { rootKeyToWords } = await import("../shared/recovery-words");447 return rootKeyToWords(rootKey);448}449450async function loadSlot(key: string): Promise<StoredVault | null> {451 const result = await chrome.storage.local.get(key);452 return (result as Record<string, StoredVault | undefined>)[key] ?? null;453}454455async function writeSlot(key: string, vault: StoredVault): Promise<void> {456 try {457 await chrome.storage.local.set({ [key]: vault });458 } catch (err) {459 throw mapStorageError(err);460 }461}462463464465466467468async function ensureMigrated(): Promise<void> {469 if (migrationRan) return;470 migrationRan = true;471472 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];473 const raw = await chrome.storage.local.get(keys);474 const slotAExists = raw[SLOT_A] !== undefined;475 const slotBExists = raw[SLOT_B] !== undefined;476 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;477 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;478479 480 if (!slotAExists && !legacyMain) return;481482 483 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;484485 if (legacyMain && !slotAExists) {486 await writeSlot(SLOT_A, legacyMain);487 }488489 490 if (!slotBExists) {491 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;492 if (legacyDecoy) {493 await writeSlot(SLOT_B, legacyDecoy);494 } else if (target) {495 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));496 }497 }498499 const toRemove: string[] = [];500 if (legacyMain) toRemove.push(LEGACY_MAIN);501 if (legacyDecoy) toRemove.push(LEGACY_DECOY);502 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);503 if (toRemove.length > 0) {504 await chrome.storage.local.remove(toRemove);505 }506}507508509510511512513514515516517518function padPlaintext(json: string): string {519 const encoded = new TextEncoder().encode(json);520 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);521 const padLen = target - encoded.length;522 return json + " ".repeat(padLen);523}524525526527528529530531532533534535const CSPRNG_CHUNK = 65_536;536537function fillRandom(buf: Uint8Array): Uint8Array {538 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {539 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);540 const chunk = new Uint8Array(end - offset);541 crypto.getRandomValues(chunk);542 buf.set(chunk, offset);543 }544 return buf;545}546547function generateNoiseBlob(ciphertextBytes: number): StoredVault {548 const salt = crypto.getRandomValues(new Uint8Array(16));549 const nonce = crypto.getRandomValues(new Uint8Array(12));550 const cipher = fillRandom(new Uint8Array(ciphertextBytes));551 const tag = crypto.getRandomValues(new Uint8Array(16));552 return {553 version: 1,554 salt: hex(salt),555 nonce: hex(nonce),556 ciphertext: hex(cipher),557 tag: hex(tag),558 };559}560561562563564565type VaultPayload = {566 entries: VaultEntry[];567 duressConfigured?: boolean;568};569570type DecryptedPayload = {571 entries: VaultEntry[];572 duressConfigured: boolean;573 574575576577578579580 migratedFromLegacy: boolean;581};582583function encodePayload(entries: VaultEntry[], duress: boolean): string {584 const envelope: VaultPayload = { entries, duressConfigured: duress };585 return padPlaintext(JSON.stringify(envelope));586}587588function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {589 if ("created" in raw || "updated" in raw) return true;590 const flat = raw as Partial<VaultEntry>;591 if (flat.type === "passkey") return true;592 593 if (flat.rpId !== undefined && flat.passkey === undefined) return true;594 return false;595}596597function decodePayload(plain: string): DecryptedPayload {598 const parsed = JSON.parse(plain) as599 | Array<VaultEntry | LegacyVaultEntry>600 | VaultPayload;601602 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];603 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);604605 const normalized = rawEntries.map(normalizeEntry);606 const { merged } = mergePasskeyOnlyIntoLogins(normalized);607608 if (Array.isArray(parsed)) {609 return {610 entries: normalized,611 duressConfigured: false,612 migratedFromLegacy: true, 613 };614 }615616 return {617 entries: normalized,618 duressConfigured: parsed.duressConfigured === true,619 migratedFromLegacy: flatMigrationNeeded || merged,620 };621}622623624625626627628629630631632633634635636637638639640641642643644645646function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {647 let merged = false;648 const now = Date.now();649650 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);651 const byRpId = new Map<string, VaultEntry[]>();652 for (const e of candidates) {653 const rpId = e.passkey!.rpId;654 const list = byRpId.get(rpId);655 if (list) list.push(e);656 else byRpId.set(rpId, [e]);657 }658659 for (const [rpId, group] of byRpId) {660 if (group.length === 0) continue;661 group.sort((a, b) => {662 const aT =663 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;664 const bT =665 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;666 return bT - aT;667 });668 const winner = group[0]!;669670 671 672 673 const loginTarget = entries.find((e) => {674 if (e.deletedAt || e.passkey || e.type !== "login") return false;675 if (e.url && extractDomain(e.url) === rpId) return true;676 for (const extra of e.additionalUrls ?? []) {677 if (extractDomain(extra) === rpId) return true;678 }679 return false;680 });681682 if (loginTarget) {683 loginTarget.passkey = winner.passkey;684 loginTarget.modifiedAt = now;685 for (const dup of group) {686 if (!dup.deletedAt) {687 dup.deletedAt = now;688 dup.modifiedAt = now;689 }690 }691 merged = true;692 } else if (group.length > 1) {693 for (let i = 1; i < group.length; i += 1) {694 const dup = group[i]!;695 if (!dup.deletedAt) {696 dup.deletedAt = now;697 dup.modifiedAt = now;698 }699 }700 merged = true;701 }702 }703704 return { merged };705}706707async function tryDecryptSlot(708 vault: StoredVault,709 key: Uint8Array710): Promise<DecryptedPayload | null> {711 try {712 const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);713 return decodePayload(plain);714 } catch {715 return null;716 }717}718719720721722723function buildDomainIndexSync(all: VaultEntry[]): void {724 domainIndex = new Map();725726 for (const entry of all) {727 if (entry.deletedAt) continue;728 if (entry.type === "note" || entry.type === "card") continue;729730 731 732 733 734 const domains = new Set<string>();735 const primary = extractDomain(entry.url ?? "");736 if (primary) domains.add(primary);737 for (const extra of entry.additionalUrls ?? []) {738 const d = extractDomain(extra);739 if (d) domains.add(d);740 }741 if (domains.size === 0) continue;742743 for (const domain of domains) {744 const bucket = domainIndex.get(domain) ?? [];745 bucket.push(entry);746 domainIndex.set(domain, bucket);747 }748 }749}750751export async function createVault(password: string): Promise<void> {752 await ensureMigrated();753754 const salt = generateSalt();755 derivedKey = await deriveKey(password, salt);756 entries = [];757 vaultMode = "real";758 duressConfigured = false;759 buildDomainIndexSync(entries);760761 762 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =763 await generateAndWrapRootKey();764 rootKey = unhex(rawRootKey);765766 const plaintext = encodePayload(entries, duressConfigured);767 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);768769 const slotA: StoredVault = {770 version: 1,771 salt: hex(salt),772 nonce,773 ciphertext,774 tag,775 recovery_salt: recoverySalt,776 recovery_wrapped_root_key: wrappedRootKey,777 recovery_nonce: wrappedNonce,778 recovery_tag: wrappedTag,779 };780 await writeSlot(SLOT_A, slotA);781782 783 784 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));785786 touchAutoLock();787}788789export async function hasVault(): Promise<boolean> {790 await ensureMigrated();791 return (await loadSlot(SLOT_A)) !== null;792}793794795796797798799export async function isDuressEnabled(): Promise<boolean> {800 return vaultMode === "real" && duressConfigured;801}802803export async function hasDecoyVault(): Promise<boolean> {804 return isDuressEnabled();805}806807export async function setupDuress(duressPassword: string): Promise<void> {808 if (!derivedKey || vaultMode !== "real") {809 throw new Error("vault must be unlocked in real mode");810 }811 await ensureMigrated();812813 814 815 816 const slotA = await loadSlot(SLOT_A);817 if (!slotA) throw new Error("missing slot A");818819 const decoySalt = generateSalt();820 const decoyKey = await deriveKey(duressPassword, decoySalt);821 try {822 823 824 const targetBytes = slotA.ciphertext.length / 2;825 let plaintext = encodePayload([], false);826 const encoded = new TextEncoder().encode(plaintext);827 if (encoded.length < targetBytes) {828 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);829 }830 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);831 await writeSlot(SLOT_B, {832 version: 1,833 salt: hex(decoySalt),834 nonce,835 ciphertext,836 tag,837 });838 } finally {839 zeroKey(decoyKey);840 }841842 843 844 duressConfigured = true;845 await persistSlotA();846}847848export async function disableDuress(): Promise<void> {849 if (!derivedKey || vaultMode !== "real") {850 throw new Error("vault must be unlocked in real mode");851 }852 await ensureMigrated();853854 855 856 857 const slotA = await loadSlot(SLOT_A);858 if (slotA) {859 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));860 }861862 duressConfigured = false;863 await persistSlotA();864}865866export async function verifyMainPassword(password: string): Promise<boolean> {867 await ensureMigrated();868 const slotA = await loadSlot(SLOT_A);869 if (!slotA) return false;870 const key = await deriveKey(password, unhex(slotA.salt));871 try {872 return (await tryDecryptSlot(slotA, key)) !== null;873 } finally {874 zeroKey(key);875 }876}877878export async function unlock(password: string): Promise<boolean> {879 await ensureMigrated();880881 882 883 884 885 886 const [slotA, slotB] = await Promise.all([887 loadSlot(SLOT_A),888 loadSlot(SLOT_B),889 ]);890891 892 const [keyA, keyB] = await Promise.all([893 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),894 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),895 ]);896897 898 const [payloadA, payloadB] = await Promise.all([899 keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),900 keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),901 ]);902903 if (payloadA && keyA) {904 derivedKey = keyA;905 vaultMode = "real";906 entries = payloadA.entries;907 duressConfigured = payloadA.duressConfigured;908 buildDomainIndexSync(entries);909 await loadSettings();910 touchAutoLock();911 if (keyB) zeroKey(keyB);912913 914 if (slotA) {915 const needsRecoverySetup =916 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;917 if (!needsRecoverySetup) {918 rootKey = await deriveRootKeyFromDerivedKey(919 keyA,920 slotA.recovery_salt!921 );922 }923 }924925 926 927 928 if (payloadA.migratedFromLegacy) {929 await persistSlotA();930 }931 return true;932 }933934 if (payloadB && keyB) {935 derivedKey = keyB;936 vaultMode = "decoy";937 entries = payloadB.entries;938 duressConfigured = false;939 buildDomainIndexSync(entries);940 await loadSettings();941 touchAutoLock();942 if (keyA) zeroKey(keyA);943 return true;944 }945946 if (keyA) zeroKey(keyA);947 if (keyB) zeroKey(keyB);948 return false;949}950951export function lock(): void {952 if (derivedKey) {953 zeroKey(derivedKey);954 derivedKey = null;955 }956 if (rootKey) {957 rootKey.fill(0);958 rootKey = null;959 }960961 entries = [];962 domainIndex = new Map();963 vaultMode = "real";964 duressConfigured = false;965 clearAutoLock();966}967968export function isUnlocked(): boolean {969 return derivedKey !== null;970}971972973974export async function unlockWithKey(975 masterKeyHex: string,976): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {977 try {978 const masterKeyBytes = unhex(masterKeyHex);979 const stored = await loadSlot(SLOT_A);980 if (!stored) return { ok: false, error: "invalid_key" };981 const payload = await tryDecryptSlot(stored, masterKeyBytes);982 if (!payload) return { ok: false, error: "invalid_key" };983984 derivedKey = masterKeyBytes;985 vaultMode = "real";986 entries = payload.entries;987 duressConfigured = payload.duressConfigured;988 buildDomainIndexSync(entries);989 await loadSettings();990 touchAutoLock();991 return { ok: true, entries };992 } catch {993 return { ok: false, error: "invalid_key" };994 }995}996997998999export function getDerivedKeyHex(): string {1000 if (!derivedKey) throw new Error("vault_not_unlocked");1001 return hex(derivedKey);1002}10031004export function getEntries(): VaultEntry[] {1005 if (!derivedKey) throw new Error("vault locked");1006 return entries.filter((entry) => !entry.deletedAt);1007}10081009const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;10101011export function getIdentities(): SavedIdentity[] {1012 if (!derivedKey) throw new Error("vault locked");1013 const active = entries.filter((e) => !e.deletedAt && e.type === "login");10141015 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1016 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();10171018 for (const entry of active) {1019 const value = entry.username?.trim();1020 if (!value) continue;1021 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;1022 const existing = map.get(value);1023 const domain = extractDomain(entry.url ?? "") ?? "";1024 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;1025 if (!existing || modifiedAt > existing.lastUsed) {1026 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1027 } else {1028 map.set(value, { ...existing, count: existing.count + 1 });1029 }1030 }10311032 const toIdentity = (type: "email" | "username") =>1033 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1034 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1035 });10361037 return [1038 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1039 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1040 ];1041}10421043export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1044 if (!derivedKey) throw new Error("vault locked");10451046 const now = Date.now();1047 const added: VaultEntry[] = [];1048 for (const draft of drafts) {1049 try {1050 const sanitized = sanitizeEntryDraft(draft);1051 const nextEntry: VaultEntry = {1052 ...sanitized,1053 id: crypto.randomUUID(),1054 createdAt: now,1055 modifiedAt: now,1056 };1057 entries.push(nextEntry);1058 added.push(nextEntry);1059 } catch {1060 1061 }1062 }10631064 if (added.length > 0) {1065 await persistEntries();1066 }10671068 return added;1069}107010711072107310741075107610771078export async function restoreEntries(1079 incoming: VaultEntry[],1080 mode: "replace" | "merge"1081): Promise<{ added: number; updated: number; skipped: number }> {1082 if (!derivedKey) throw new Error("vault locked");10831084 1085 1086 1087 if (mode === "replace" && incoming.length === 0) {1088 throw new Error("refuse to replace with empty entry set");1089 }10901091 let added = 0;1092 let updated = 0;1093 let skipped = 0;10941095 if (mode === "replace") {1096 entries = [];1097 for (const entry of incoming) {1098 const normalized = sanitizeRestoredEntry(entry);1099 if (!normalized) {1100 skipped += 1;1101 continue;1102 }1103 entries.push(normalized);1104 added += 1;1105 }1106 } else {1107 const byId = new Map<string, VaultEntry>();1108 for (const e of entries) byId.set(e.id, e);11091110 for (const entry of incoming) {1111 const normalized = sanitizeRestoredEntry(entry);1112 if (!normalized) {1113 skipped += 1;1114 continue;1115 }1116 const existing = byId.get(normalized.id);1117 if (!existing) {1118 entries.push(normalized);1119 added += 1;1120 } else if (normalized.modifiedAt > existing.modifiedAt) {1121 const idx = entries.findIndex((e) => e.id === existing.id);1122 if (idx !== -1) {1123 entries[idx] = normalized;1124 updated += 1;1125 }1126 } else {1127 skipped += 1;1128 }1129 }1130 }11311132 await persistEntries("import");1133 return { added, updated, skipped };1134}1135113611371138113911401141function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1142 if (!entry || typeof entry !== "object") return null;1143 const title = typeof entry.title === "string" ? entry.title.trim() : "";1144 if (!title) return null;1145 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1146 const now = Date.now();1147 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1148 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;11491150 1151 1152 1153 const migrated: VaultEntry = migrateLegacyPasskey(1154 {1155 ...entry,1156 id,1157 title,1158 type: entry.type ?? "login",1159 createdAt,1160 modifiedAt,1161 },1162 entry1163 );11641165 1166 1167 if (1168 migrated.type === "login" &&1169 !migrated.passkey &&1170 typeof migrated.password !== "string"1171 ) {1172 return null;1173 }1174 if (migrated.passkey && !migrated.passkey.credentialId) {1175 return null;1176 }11771178 return migrated;1179}118011811182118311841185export async function clearVaultEntries(1186 confirmation: "REPLACE_ALL"1187): Promise<void> {1188 if (confirmation !== "REPLACE_ALL") {1189 throw new Error("clearVaultEntries requires explicit confirmation");1190 }1191 if (!derivedKey) throw new Error("vault locked");1192 for (const entry of entries) {1193 if (!entry.deletedAt) {1194 entry.deletedAt = Date.now();1195 entry.modifiedAt = Date.now();1196 }1197 }1198 await persistEntries();1199}12001201export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1202 if (!derivedKey) throw new Error("vault locked");12031204 const sanitized = sanitizeEntryDraft(entry);1205 const now = Date.now();1206 const nextEntry: VaultEntry = {1207 ...sanitized,1208 id: crypto.randomUUID(),1209 createdAt: now,1210 modifiedAt: now,1211 1212 1213 passwordChangedAt: sanitized.password ? now : undefined,1214 };12151216 entries.push(nextEntry);1217 await persistEntries();12181219 1220 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1221 checkBreach(nextEntry.password).then(async (count) => {1222 if (count > 0) {1223 const idx = entries.findIndex((e) => e.id === nextEntry.id);1224 if (idx !== -1) {1225 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1226 await persistEntries();1227 }1228 }1229 });1230 }12311232 return nextEntry;1233}12341235export async function updateEntry(1236 id: string,1237 patch: Partial<VaultEntry>1238): Promise<void> {1239 if (!derivedKey) throw new Error("vault locked");12401241 const idx = entries.findIndex((entry) => entry.id === id);1242 if (idx === -1 || entries[idx].deletedAt) {1243 throw new Error("entry not found");1244 }12451246 const current = entries[idx];1247 const sanitizedPatch = sanitizeEntryPatch(patch);1248 1249 1250 1251 const passwordChanged =1252 "password" in patch &&1253 typeof sanitizedPatch.password === "string" &&1254 sanitizedPatch.password !== current.password;1255 entries[idx] = {1256 ...current,1257 ...sanitizedPatch,1258 modifiedAt: Date.now(),1259 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1260 };12611262 1263 1264 1265 1266 if (1267 entries[idx].type === "login" &&1268 !entries[idx].password &&1269 !entries[idx].passkey1270 ) {1271 entries[idx] = current;1272 throw new Error("password or passkey required");1273 }12741275 1276 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1277 entries[idx] = current;1278 throw new Error("card number is required for card entries");1279 }12801281 await persistEntries();12821283 1284 1285 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1286 entries[idx] = { ...entries[idx], breach: undefined };1287 await persistEntries();1288 const password = entries[idx].password;1289 if (password) {1290 checkBreach(password).then(async (count) => {1291 const i = entries.findIndex((e) => e.id === id);1292 if (i !== -1 && count > 0) {1293 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1294 await persistEntries();1295 }1296 });1297 }1298 }1299}13001301export async function deleteEntry(id: string): Promise<void> {1302 if (!derivedKey) throw new Error("vault locked");13031304 const entry = entries.find((item) => item.id === id);1305 if (!entry || entry.deletedAt) {1306 throw new Error("entry not found");1307 }13081309 entry.deletedAt = Date.now();1310 entry.modifiedAt = Date.now();1311 await persistEntries();1312}13131314export function getDeletedEntries(): VaultEntry[] {1315 if (!derivedKey) return [];1316 return entries.filter((e) => !!e.deletedAt);1317}13181319export async function restoreEntry(id: string): Promise<void> {1320 if (!derivedKey) throw new Error("vault locked");1321 const entry = entries.find((e) => e.id === id && e.deletedAt);1322 if (!entry) throw new Error("deleted entry not found");1323 delete entry.deletedAt;1324 entry.modifiedAt = Date.now();1325 await persistEntries();1326 buildDomainIndexSync(entries);1327}13281329export async function purgeEntry(id: string): Promise<void> {1330 if (!derivedKey) throw new Error("vault locked");1331 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1332 if (idx === -1) throw new Error("deleted entry not found");1333 entries.splice(idx, 1);1334 await persistEntries();1335}133613371338export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1339 if (!derivedKey || retentionMs === 0) return 0;1340 const cutoff = Date.now() - retentionMs;1341 const before = entries.length;1342 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1343 const purged = before - entries.length;1344 if (purged > 0) await persistEntries();1345 return purged;1346}13471348export function queryDomain(domain: string): VaultEntry[] {1349 return domainIndex.get(domain) ?? [];1350}13511352export function getEntryById(id: string): VaultEntry | undefined {1353 return entries.find((entry) => entry.id === id && !entry.deletedAt);1354}13551356async function persistSlotA(1357 reason: VaultSnapshot["reason"] = "local_save",1358): Promise<StoredVault> {1359 if (!derivedKey) throw new Error("vault locked");13601361 const slotA = await loadSlot(SLOT_A);1362 if (!slotA) throw new Error("missing slot A");13631364 1365 1366 await takeVaultSnapshot(reason);13671368 const plaintext = encodePayload(entries, duressConfigured);1369 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);13701371 1372 const nextSlotA: StoredVault = {1373 ...slotA,1374 nonce,1375 ciphertext,1376 tag,1377 1378 };1379 await writeSlot(SLOT_A, nextSlotA);13801381 1382 1383 1384 if (!duressConfigured) {1385 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1386 }13871388 return nextSlotA;1389}13901391async function persistSlotB(): Promise<void> {1392 if (!derivedKey) throw new Error("vault locked");13931394 const slotB = await loadSlot(SLOT_B);1395 if (!slotB) throw new Error("missing slot B");13961397 1398 1399 const plaintext = encodePayload(entries, false);1400 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);14011402 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1403}14041405async function persistEntries(1406 reason: VaultSnapshot["reason"] = "local_save",1407): Promise<void> {1408 if (!derivedKey) throw new Error("vault locked");14091410 if (vaultMode === "decoy") {1411 await persistSlotB();1412 } else {1413 await persistSlotA(reason);1414 }1415 buildDomainIndexSync(entries);1416}14171418function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1419 if (!Array.isArray(values)) return undefined;1420 const seen = new Set<string>();1421 const cleaned: string[] = [];1422 for (const value of values) {1423 const trimmed = typeof value === "string" ? value.trim() : "";1424 if (!trimmed) continue;1425 const key = trimmed.toLowerCase();1426 if (seen.has(key)) continue;1427 seen.add(key);1428 cleaned.push(trimmed);1429 }1430 return cleaned.length > 0 ? cleaned : undefined;1431}14321433function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1434 const title = entry.title.trim();1435 1436 1437 1438 let type: VaultEntry["type"] = entry.type ?? "login";1439 if (type === "passkey") type = "login";14401441 if (!title) throw new Error("title is required");1442 1443 1444 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1445 throw new Error("password or passkey required");1446 }1447 if (entry.passkey) {1448 const p = entry.passkey;1449 if (1450 !p.rpId ||1451 !p.credentialId ||1452 !p.publicKeyCose ||1453 !p.userHandle ||1454 !p.userName ||1455 (p.algorithm !== -7 && p.algorithm !== -8)1456 ) {1457 throw new Error("invalid passkey credential");1458 }1459 }1460 if (type === "card") {1461 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1462 throw new Error("cardholder name and card number are required");1463 }1464 }14651466 return {1467 type,1468 title,1469 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1470 password: type === "login" ? entry.password : undefined,1471 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1472 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1473 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1474 notes: entry.notes?.trim() || undefined,1475 breach: type === "login" ? entry.breach : undefined,1476 canary: type === "login" ? entry.canary : undefined,1477 tags: entry.tags,1478 passkey: entry.passkey,1479 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1480 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1481 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1482 expYear: type === "card" ? entry.expYear?.trim() : undefined,1483 cvc: type === "card" ? entry.cvc?.trim() : undefined,1484 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1485 network: type === "card" ? entry.network : undefined,1486 last4: type === "card" ? entry.last4?.trim() : undefined,1487 };1488}14891490function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1491 const nextPatch: Partial<VaultEntry> = {};14921493 if ("type" in patch) {1494 nextPatch.type = patch.type;1495 }14961497 if ("title" in patch) {1498 const title = patch.title?.trim() ?? "";1499 if (!title) throw new Error("title is required");1500 nextPatch.title = title;1501 }15021503 if ("url" in patch) {1504 nextPatch.url = patch.url?.trim() ?? "";1505 }15061507 if ("additionalUrls" in patch) {1508 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1509 const seen = new Set<string>();1510 const cleaned: string[] = [];1511 for (const value of incoming) {1512 const trimmed = typeof value === "string" ? value.trim() : "";1513 if (!trimmed) continue;1514 const key = trimmed.toLowerCase();1515 if (seen.has(key)) continue;1516 seen.add(key);1517 cleaned.push(trimmed);1518 }1519 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1520 }15211522 if ("username" in patch) {1523 nextPatch.username = patch.username?.trim() ?? "";1524 }15251526 if ("password" in patch) {1527 const password = patch.password ?? "";1528 1529 nextPatch.password = password || undefined;1530 }15311532 if ("totp" in patch) {1533 nextPatch.totp = patch.totp?.trim() || undefined;1534 }15351536 if ("notes" in patch) {1537 nextPatch.notes = patch.notes?.trim() || undefined;1538 }15391540 if ("breach" in patch) {1541 nextPatch.breach = patch.breach;1542 }15431544 if ("canary" in patch) {1545 nextPatch.canary = patch.canary;1546 }15471548 if ("passkey" in patch) {1549 1550 1551 1552 nextPatch.passkey = patch.passkey;1553 }15541555 if ("tags" in patch) {1556 nextPatch.tags = patch.tags;1557 }15581559 if ("cardholderName" in patch) {1560 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1561 }1562 if ("cardNumber" in patch) {1563 const n = patch.cardNumber ?? "";1564 nextPatch.cardNumber = n || undefined;1565 }1566 if ("expMonth" in patch) {1567 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1568 }1569 if ("expYear" in patch) {1570 nextPatch.expYear = patch.expYear?.trim() || undefined;1571 }1572 if ("cvc" in patch) {1573 nextPatch.cvc = patch.cvc?.trim() || undefined;1574 }1575 if ("cardNickname" in patch) {1576 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1577 }1578 if ("network" in patch) {1579 nextPatch.network = patch.network;1580 }1581 if ("last4" in patch) {1582 nextPatch.last4 = patch.last4?.trim() || undefined;1583 }15841585 return nextPatch;1586}15871588function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1589 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1590 const modifiedAt =1591 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;15921593 const base: VaultEntry = {1594 id: entry.id,1595 type: "type" in entry ? entry.type : "login",1596 title: entry.title,1597 username: entry.username,1598 password: entry.password,1599 url: entry.url,1600 totp: entry.totp,1601 notes: entry.notes,1602 breach: entry.breach,1603 canary: "canary" in entry ? entry.canary : undefined,1604 passkey: "passkey" in entry ? entry.passkey : undefined,1605 createdAt,1606 modifiedAt,1607 deletedAt: entry.deletedAt,1608 tags: "tags" in entry ? entry.tags : undefined,1609 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1610 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1611 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1612 expYear: "expYear" in entry ? entry.expYear : undefined,1613 cvc: "cvc" in entry ? entry.cvc : undefined,1614 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1615 network: "network" in entry ? entry.network : undefined,1616 last4: "last4" in entry ? entry.last4 : undefined,1617 };16181619 return migrateLegacyPasskey(base, entry);1620}162116221623162416251626162716281629function migrateLegacyPasskey(1630 base: VaultEntry,1631 raw: VaultEntry | LegacyVaultEntry1632): VaultEntry {1633 if (base.passkey) return base;1634 const flat = raw as Partial<VaultEntry>;1635 const isPasskeyType = base.type === "passkey";1636 const hasFlatPasskeyFields =1637 typeof flat.rpId === "string" &&1638 typeof flat.credentialId === "string" &&1639 typeof flat.publicKeyCose === "string" &&1640 typeof flat.userHandle === "string" &&1641 typeof flat.userName === "string";1642 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1643 if (!hasFlatPasskeyFields) return base;16441645 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1646 base.passkey = {1647 rpId: flat.rpId!,1648 rpName: flat.rpName ?? flat.rpId!,1649 credentialId: flat.credentialId!,1650 publicKeyCose: flat.publicKeyCose!,1651 privateKeyJwk: flat.privateKeyJwk,1652 userHandle: flat.userHandle!,1653 userName: flat.userName!,1654 userDisplayName: flat.userDisplayName ?? flat.userName!,1655 algorithm,1656 signCount: flat.signCount ?? 0,1657 aaguid: flat.aaguid ?? "",1658 backupEligible: flat.backupEligible === true,1659 backupState: flat.backupState === true,1660 lastUsedAt: flat.lastUsedAt,1661 browser: flat.browser,1662 transports: flat.transports,1663 createdAt: base.createdAt,1664 };1665 1666 1667 1668 if (isPasskeyType) base.type = "login";1669 if (!base.username) base.username = flat.userName;1670 if (!base.url) base.url = `https://${flat.rpId}`;1671 return base;1672}1673167416751676167716781679function entryDupeKey(e: VaultEntry): string {1680 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1681 1682 1683 1684 1685 if (e.passkey) {1686 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1687 }1688 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1689}169016911692export function countDuplicates(): number {1693 const best = new Map<string, VaultEntry>();1694 let dupeCount = 0;1695 for (const e of entries.filter((e) => !e.deletedAt)) {1696 const key = entryDupeKey(e);1697 const existing = best.get(key);1698 if (!existing) {1699 best.set(key, e);1700 } else {1701 dupeCount++;1702 1703 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1704 best.set(key, e);1705 }1706 }1707 }1708 return dupeCount;1709}171017111712171317141715export async function deduplicateEntries(): Promise<number> {1716 if (!derivedKey) throw new Error("vault locked");1717 1718 1719 if (vaultMode === "decoy") {1720 throw new Error("deduplicate disabled in decoy mode");1721 }17221723 const best = new Map<string, VaultEntry>();1724 for (const e of entries.filter((en) => !en.deletedAt)) {1725 const key = entryDupeKey(e);1726 const existing = best.get(key);1727 if (!existing) {1728 best.set(key, e);1729 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1730 best.set(key, e);1731 }1732 }17331734 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1735 const now = Date.now();1736 let removed = 0;1737 entries = entries.map((e) => {1738 if (!e.deletedAt && !keepIds.has(e.id)) {1739 removed++;1740 return { ...e, deletedAt: now };1741 }1742 return e;1743 });17441745 if (removed > 0) await persistEntries();1746 return removed;1747}174817491750175117521753175417551756const HISTORY_KEY = "peach_vault_history";1757const HISTORY_MAX = 10;17581759176017611762176317641765export async function takeVaultSnapshot(1766 reason: VaultSnapshot["reason"],1767): Promise<void> {1768 try {1769 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);1770 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];1771 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];17721773 1774 1775 1776 if (!slotA) return;17771778 const vault_a = JSON.stringify(slotA);1779 const vault_b = JSON.stringify(slotB ?? slotA);17801781 1782 1783 1784 let entryCount: number | null = null;1785 if (derivedKey && vaultMode === "real") {1786 try {1787 entryCount = entries.filter((e) => !e.deletedAt).length;1788 } catch {1789 entryCount = null;1790 }1791 }17921793 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1794 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)1795 ? existingRaw1796 : { snapshots: [] };17971798 1799 1800 1801 const latest = history.snapshots[0];1802 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {1803 return;1804 }18051806 const snapshot: VaultSnapshot = {1807 id: crypto.randomUUID(),1808 savedAt: Date.now(),1809 reason,1810 vault_a,1811 vault_b,1812 entryCount,1813 };18141815 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);1816 await chrome.storage.local.set({ [HISTORY_KEY]: history });1817 } catch {1818 1819 1820 }1821}182218231824export async function clearVaultHistory(): Promise<void> {1825 try {1826 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });1827 } catch {1828 1829 }1830}183118321833export async function getVaultHistory(): Promise<VaultSnapshot[]> {1834 const raw = await chrome.storage.local.get(HISTORY_KEY);1835 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1836 if (!history || !Array.isArray(history.snapshots)) return [];1837 return history.snapshots;1838}18391840184118421843184418451846export async function restoreVaultSnapshot(1847 snapshotId: string,1848): Promise<{ ok: true } | { ok: false; error: string }> {1849 const history = await getVaultHistory();1850 const snapshot = history.find((s) => s.id === snapshotId);1851 if (!snapshot) return { ok: false, error: "snapshot_not_found" };18521853 1854 1855 1856 await takeVaultSnapshot("restore");18571858 let restoredA: StoredVault;1859 let restoredB: StoredVault;1860 try {1861 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;1862 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;1863 } catch {1864 return { ok: false, error: "snapshot_corrupt" };1865 }18661867 await writeSlot(SLOT_A, restoredA);1868 await writeSlot(SLOT_B, restoredB);18691870 1871 1872 1873 lock();18741875 return { ok: true };1876}18771878187918801881188218831884export async function changePassword(1885 currentPassword: string,1886 newPassword: string1887): Promise<boolean> {1888 if (!derivedKey || vaultMode !== "real") return false;18891890 1891 const valid = await verifyMainPassword(currentPassword);1892 if (!valid) return false;18931894 const slotA = await loadSlot(SLOT_A);1895 if (!slotA) return false;18961897 1898 const newSalt = generateSalt();1899 const newKey = await deriveKey(newPassword, newSalt);19001901 1902 const plaintext = encodePayload(entries, duressConfigured);1903 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);19041905 const freshSlotA: StoredVault = {1906 ...slotA,1907 salt: hex(newSalt),1908 nonce,1909 ciphertext,1910 tag,1911 };1912 1913 1914 1915 await takeVaultSnapshot("local_save");1916 await writeSlot(SLOT_A, freshSlotA);19171918 1919 1920 1921 zeroKey(derivedKey);1922 derivedKey = newKey;19231924 1925 1926 const {1927 rawRootKey: freshRawKey,1928 recoverySalt: freshSalt,1929 wrappedRootKey: freshWrapped,1930 wrappedNonce: freshNonce,1931 wrappedTag: freshTag,1932 } = await generateAndWrapRootKey();1933 rootKey = unhex(freshRawKey);1934 const slotAfterRekey = await loadSlot(SLOT_A);1935 if (slotAfterRekey) {1936 await writeSlot(SLOT_A, {1937 ...slotAfterRekey,1938 recovery_salt: freshSalt,1939 recovery_wrapped_root_key: freshWrapped,1940 recovery_nonce: freshNonce,1941 recovery_tag: freshTag,1942 });1943 }19441945 1946 1947 1948 if (!duressConfigured) {1949 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1950 }19511952 return true;1953}