/** * vault.ts — sole owner of vault state and chrome.storage vault keys. * * Every read/write of `peach_vault_a`, `peach_vault_b`, and `peach_settings` * goes through this module. No other file accesses those keys directly. * External callers reach vault operations via `messages.ts` — never by * importing from here cross-boundary (popup/content must not). Invariants * for the two-slot schema, unlock timing, and key/disk atomicity are in * Projects.md §3.2–§3.6. */ import { deriveKey, generateSalt } from "../crypto/argon2"; import { extractDomain } from "../shared/domain"; import { rootKeyToWords, wordsToRootKeyMaterial, deriveRecoveryKey, } from "../shared/recovery-words"; import type { AccentColorName, LegacyVaultEntry, ManualSummonRecord, SavedIdentity, StoredVault, VaultEntry, VaultHistory, VaultSnapshot, } from "../shared/types"; import { decrypt, encrypt, zeroKey } from "./aes"; import { clearAutoLock, touchAutoLock } from "./alarms"; type EntryDraft = Omit< VaultEntry, "id" | "createdAt" | "modifiedAt" | "deletedAt" >; // Slot-neutral storage keys (v1.4.24+). Both slots always exist; slot B // either holds the decoy vault (when duress is configured) or random noise // that looks identical at rest. No external indicator is stored — duress // configuration is recorded only inside the encrypted payload of slot A. const SLOT_A = "peach_vault_a"; const SLOT_B = "peach_vault_b"; // Legacy keys, migrated away on first access. const LEGACY_MAIN = "peach_vault"; const LEGACY_DECOY = "peach_decoy_vault"; const LEGACY_DURESS_FLAG = "peach_duress"; // Plaintext is padded to the next multiple of this size before encryption. // This coarsens ciphertext-length leakage into 1 KiB buckets. const PAD_BLOCK = 1024; let derivedKey: Uint8Array | null = null; // 32-byte random root key, separate from the derivedKey used for vault encryption. // The root key is wrapped (encrypted) under the recovery key derived from the // 12-word phrase. When the user changes their password, only the wrapping changes; // the root key and recovery words remain stable. let rootKey: Uint8Array | null = null; let entries: VaultEntry[] = []; let domainIndex: Map = new Map(); let vaultMode: "real" | "decoy" = "real"; // Cached post-unlock view of whether the real vault records that duress is // configured. Only meaningful after a real-mode unlock; for decoy-mode unlock // this stays false because we never see slot A's plaintext. let duressConfigured = false; let migrationRan = false; // --------------------------------------------------------------------------- // Settings (persisted inside the encrypted vault store as a sidecar key) // --------------------------------------------------------------------------- export type DisplayFont = "cormorant-italic" | "cormorant" | "inter" | "system"; export type TextSize = "small" | "medium" | "large"; export interface VaultSettings { accentColor?: AccentColorName; theme?: "dark" | "light"; autoLockMinutes?: number; clipboardClearSeconds?: number; lockOnClose?: boolean; breachMonitoring?: boolean; passkeyProviderEnabled?: boolean; trashRetentionDays?: number; manualSummonDomains?: Record; declinedCardFingerprints?: string[]; /** * Display font for headlines, brand wordmark, and narrative copy. Default * is "cormorant-italic" — the bold italic serif that ships as Peach's * voice. Non-italic options exist for users who prefer upright type. */ displayFont?: DisplayFont; /** * Text size scale for body copy and entry titles. Stops at "large" by * design — XL clips dense screens (Vault, Health) inside the fixed * 330×480 popup surface. */ textSize?: TextSize; /** * When true, all entrance/exit transitions and ambient animations are * collapsed to instant. Honoured alongside the user's OS-level * `prefers-reduced-motion`. */ reduceMotion?: boolean; /** * User overrides for keyboard shortcuts. Keys are ShortcutAction names * (see src/shared/shortcuts.ts). Missing actions fall through to * DEFAULT_BINDINGS — `getActiveBindings(settings.shortcuts)` is the * canonical lookup. Storing only the deltas keeps the vault payload * small and lets default updates land for users who never rebind. */ shortcuts?: Record; } let settings: VaultSettings = {}; export function getSettings(): VaultSettings { return settings; } export async function ensureSettingsLoaded(): Promise { await loadSettings(); return settings; } export async function updateSettings(patch: Partial): Promise { await loadSettings(); settings = { ...settings, ...patch }; await persistSettings(); } async function persistSettings(): Promise { try { await chrome.storage.local.set({ peach_settings: settings }); } catch (err) { throw mapStorageError(err); } } function mapStorageError(err: unknown): Error { const message = err instanceof Error ? err.message : String(err); if (/QUOTA_BYTES|quota/i.test(message)) { return new Error("STORAGE_QUOTA_EXCEEDED"); } return err instanceof Error ? err : new Error(message); } async function loadSettings(): Promise { const result = await chrome.storage.local.get("peach_settings"); settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {}; } // --------------------------------------------------------------------------- // HIBP breach check (k-anonymity model) // --------------------------------------------------------------------------- export async function checkBreach(password: string): Promise { const encoder = new TextEncoder(); const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password)); const hashHex = Array.from(new Uint8Array(hashBuffer)) .map((b) => b.toString(16).padStart(2, "0")) .join("") .toUpperCase(); const prefix = hashHex.slice(0, 5); const suffix = hashHex.slice(5); try { const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`); if (!res.ok) return 0; const text = await res.text(); const match = text.split("\r\n").find((l) => l.startsWith(suffix)); return match ? parseInt(match.split(":")[1], 10) : 0; } catch { return 0; // Network unavailable — don't block } } export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise { const fingerprint = await cardFingerprint(last4, expMonth, expYear); await loadSettings(); settings = { ...settings, declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint], }; await persistSettings(); } export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise { await loadSettings(); const fingerprint = await cardFingerprint(last4, expMonth, expYear); return (settings.declinedCardFingerprints ?? []).includes(fingerprint); } async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise { const encoder = new TextEncoder(); const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`)); return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join(""); } function hex(buf: Uint8Array): string { return Array.from(buf) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } function unhex(s: string): Uint8Array { const out = new Uint8Array(s.length / 2); for (let i = 0; i < s.length; i += 2) { out[i / 2] = parseInt(s.slice(i, i + 2), 16); } return out; } // --------------------------------------------------------------------------- // Root key generation and recovery word wrapping // --------------------------------------------------------------------------- /** * Generate a random 32-byte root key and wrap it (AES-256-GCM) under a key * derived from the recovery word material via HKDF-SHA256. The wrapped blob * fields are stored in slot A alongside the password-wrapped ciphertext. The * root key itself is kept in memory for the session so getRecoveryWords() can * encode it without re-deriving. */ async function generateAndWrapRootKey(): Promise<{ rawRootKey: string; // hex of the plaintext root key — caller stores in rootKey recoverySalt: string; wrappedRootKey: string; wrappedNonce: string; wrappedTag: string; }> { const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32)); // Derive the recovery key from the BIP39 words encoding of the root key. const words = await rootKeyToWords(rootKeyBytes); const wordMaterial = await wordsToRootKeyMaterial(words); const recoverySalt = crypto.getRandomValues(new Uint8Array(16)); const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt); // Wrap the root key with the recovery key using AES-256-GCM directly. // We hex-encode rootKeyBytes so the plaintext is always valid UTF-8. const nonce = crypto.getRandomValues(new Uint8Array(12)); const encoded = new TextEncoder().encode(hex(rootKeyBytes)); const raw = await crypto.subtle.encrypt( { name: "AES-GCM", iv: nonce, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128, }, recoveryKey, encoded ); // WebCrypto returns ciphertext || auth tag concatenated; split them. const rawU8 = new Uint8Array(raw); const ciphertext = hex(rawU8.slice(0, rawU8.length - 16)); const tag = hex(rawU8.slice(rawU8.length - 16)); return { rawRootKey: hex(rootKeyBytes), recoverySalt: hex(recoverySalt), wrappedRootKey: ciphertext, wrappedNonce: hex(nonce), wrappedTag: tag, }; } /** * Unwrap the root key using recovery words. Called during recovery flow when * the user provides their 12-word phrase and a new password. * Exported for use by the RECOVER_WITH_WORDS message handler. */ export async function unwrapRootKeyForRecovery( wrappedRootKey: string, wrappedNonce: string, wrappedTag: string, recoverySaltHex: string, words: string[] ): Promise { const wordMaterial = await wordsToRootKeyMaterial(words); const recoverySalt = unhex(recoverySaltHex); const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt); // Decrypt and cache the root key. The root key bytes were hex-encoded before // encryption so the ciphertext is valid UTF-8. We inline the AES-GCM decrypt // here to avoid passing a CryptoKey to the string-based encrypt/decrypt helpers. const combined = unhex(wrappedRootKey + wrappedNonce + wrappedTag); const nonceU8 = combined.slice(0, 12); const ciphertext = combined.slice(12, combined.length - 16); const storedTag = combined.slice(combined.length - 16); const raw = await crypto.subtle.decrypt( { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 }, recoveryKey, new Uint8Array([...ciphertext, ...storedTag]) ); const decryptedHex = new TextDecoder().decode(raw); rootKey = unhex(decryptedHex); } /** * Re-wrap the cached root key under a new derived key (from the user's new * password). Called after password change and after recovery re-key so the * same recovery words continue to work with the updated password. */ export async function rewrapRootKeyForPassword( newDerivedKey: Uint8Array, _newSalt: Uint8Array ): Promise { if (!rootKey) throw new Error("root key not set"); const { nonce, ciphertext, tag } = await encrypt( newDerivedKey, hex(rootKey) ); const slotA = await loadSlot(SLOT_A); if (!slotA) throw new Error("missing slot A"); await writeSlot(SLOT_A, { ...slotA, recovery_wrapped_root_key: ciphertext, recovery_nonce: nonce, recovery_tag: tag, // recovery_salt does NOT change when password changes — the recovery // words always derive the same key; only the wrapping key changes. }); } /** * Change the vault encryption to a new derived key (already derived from * the user's new password). Used by RECOVER_WITH_WORDS and changePassword. * Caller is responsible for key swap and snapshot. */ async function reencryptVaultForNewKey( newKey: Uint8Array, slotA: StoredVault ): Promise { const plaintext = encodePayload(entries, duressConfigured); const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext); await writeSlot(SLOT_A, { ...slotA, nonce, ciphertext, tag, }); } /** * Full password change with an already-derived key (used after recovery). * Unlike changePassword(), this skips re-verification of current password * since recovery flow has already validated the user's identity via words. */ export async function changePasswordWithNewKey( newSalt: Uint8Array, newKey: Uint8Array ): Promise { const slotA = await loadSlot(SLOT_A); if (!slotA) throw new Error("missing slot A"); await reencryptVaultForNewKey(newKey, slotA); zeroKey(derivedKey!); derivedKey = newKey; if (!duressConfigured) { await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2)); } } // --------------------------------------------------------------------------- // Salt generation --- exported so the RECOVER_WITH_WORDS handler can derive // a new key after password reset without re-importing the crypto module. // --------------------------------------------------------------------------- export { deriveKey, generateSalt }; export async function loadSlotA(): Promise { return loadSlot(SLOT_A); } /** * Re-derive the session root key from the already-derived AES key (keyA from * unlock) and the recovery salt stored in slot A. This is the inverse of * generateAndWrapRootKey() — both operations use the same HKDF with the same * salt and info string, so they cancel out. * * The derivedKey and rootKey are related by: * derivedKey = Argon2id(password, slotA.salt) → AES key for vault payload * rootKey = HKDF-SHA256(recovery_words, recovery_salt) → wrapped by HKDF * * Since we only store the wrapped root key and the recovery salt, we can * only re-derive rootKey during an active (unlocked) session where we have * the derivedKey already. On lock, rootKey is zeroed. * * For pre-v2.12 vaults that have no recovery_salt, this returns null and * the caller must invoke migrateToRecovery() to generate the recovery escrow. */ async function deriveRootKeyFromDerivedKey( _derivedKey: Uint8Array, _recoverySaltHex: string ): Promise { // The root key is stored wrapped under the recovery key derived from the // 12-word phrase. It cannot be re-derived from derivedKey alone — that would // require knowing the original recovery words. The in-memory rootKey is set // during unlock if recovery data is present. // This function exists as a potential future path if we ever store an // additional derived-key → root-key relationship. Currently rootKey is // cached directly during unlock. return null; } /** * For vaults created before v2.12 (no recovery_salt/recovery_wrapped_root_key), * generate the root key, wrap it under the recovery words, and persist the * recovery escrow into slot A. The caller must show the recovery words to the * user before calling this — once called, those words are the vault's recovery * path. This runs once, on the first unlock of an upgraded vault. */ export async function migrateToRecovery(): Promise { if (!derivedKey || vaultMode !== "real") { throw new Error("vault must be unlocked in real mode"); } const slotA = await loadSlot(SLOT_A); if (!slotA) throw new Error("missing slot A"); if (slotA.recovery_salt && slotA.recovery_wrapped_root_key) return; // already done const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } = await generateAndWrapRootKey(); rootKey = unhex(rawRootKey); const nextSlotA: StoredVault = { ...slotA, recovery_salt: recoverySalt, recovery_wrapped_root_key: wrappedRootKey, recovery_nonce: wrappedNonce, recovery_tag: wrappedTag, }; await writeSlot(SLOT_A, nextSlotA); } /** * Returns the 12 recovery words for the current session's root key. * Must be called while the vault is unlocked. Throws if recovery has not * been set up (pre-v2.12 vault that hasn't migrated yet). */ export async function getRecoveryWords(): Promise { if (!rootKey) { throw new Error("recovery not set up — vault may need migration"); } return rootKeyToWords(rootKey); } async function loadSlot(key: string): Promise { const result = await chrome.storage.local.get(key); return (result as Record)[key] ?? null; } async function writeSlot(key: string, vault: StoredVault): Promise { try { await chrome.storage.local.set({ [key]: vault }); } catch (err) { throw mapStorageError(err); } } /** * One-shot migration from the pre-A-2 schema (peach_vault + peach_decoy_vault * + peach_duress flag) to the slot-neutral schema (peach_vault_a + * peach_vault_b). Runs at most once per session; safe to call repeatedly. */ async function ensureMigrated(): Promise { if (migrationRan) return; migrationRan = true; const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B]; const raw = await chrome.storage.local.get(keys); const slotAExists = raw[SLOT_A] !== undefined; const slotBExists = raw[SLOT_B] !== undefined; const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined; const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined; // Fresh install with nothing in storage — nothing to do; createVault() sets up both slots. if (!slotAExists && !legacyMain) return; // Already migrated. if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return; if (legacyMain && !slotAExists) { await writeSlot(SLOT_A, legacyMain); } // If slot B isn't set, populate it: use legacy decoy when present, else noise. if (!slotBExists) { const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined; if (legacyDecoy) { await writeSlot(SLOT_B, legacyDecoy); } else if (target) { await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2)); } } const toRemove: string[] = []; if (legacyMain) toRemove.push(LEGACY_MAIN); if (legacyDecoy) toRemove.push(LEGACY_DECOY); if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG); if (toRemove.length > 0) { await chrome.storage.local.remove(toRemove); } } // --------------------------------------------------------------------------- // Padding + noise helpers // --------------------------------------------------------------------------- /** * Pads the plaintext string with trailing spaces so that the encoded byte * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing * whitespace after a complete document, so the padding is transparent on * decode. Ciphertext length equals plaintext length plus a 16-byte tag. */ function padPlaintext(json: string): string { const encoded = new TextEncoder().encode(json); const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK); const padLen = target - encoded.length; return json + " ".repeat(padLen); } /** * Produces a StoredVault-shaped record filled with CSPRNG bytes. The * ciphertext length matches the given byte count. Because GCM ciphertext is * pseudo-random, this blob is indistinguishable at rest from a real * encrypted vault without the key. * * crypto.getRandomValues() caps each call at 65536 bytes (the Web Crypto * spec hard limit). Chunk the fill so vaults larger than that — common once * the user has dozens of entries or any passkey — don't throw. */ const CSPRNG_CHUNK = 65_536; function fillRandom(buf: Uint8Array): Uint8Array { for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) { const end = Math.min(offset + CSPRNG_CHUNK, buf.length); const chunk = new Uint8Array(end - offset); crypto.getRandomValues(chunk); buf.set(chunk, offset); } return buf; } function generateNoiseBlob(ciphertextBytes: number): StoredVault { const salt = crypto.getRandomValues(new Uint8Array(16)); const nonce = crypto.getRandomValues(new Uint8Array(12)); const cipher = fillRandom(new Uint8Array(ciphertextBytes)); const tag = crypto.getRandomValues(new Uint8Array(16)); return { version: 1, salt: hex(salt), nonce: hex(nonce), ciphertext: hex(cipher), tag: hex(tag), }; } // --------------------------------------------------------------------------- // Encrypted plaintext envelope // --------------------------------------------------------------------------- type VaultPayload = { entries: VaultEntry[]; duressConfigured?: boolean; }; type DecryptedPayload = { entries: VaultEntry[]; duressConfigured: boolean; /** * True when at least one entry in the on-disk payload had a legacy shape * (pre-v2.11.0 passkey type/flat fields, or pre-A-2 created/updated keys) * and got rewritten by normalizeEntry. The unlock path uses this to * persist the new shape immediately — without it, the migration would * re-run on every unlock and old shape never disappears from disk. */ migratedFromLegacy: boolean; }; function encodePayload(entries: VaultEntry[], duress: boolean): string { const envelope: VaultPayload = { entries, duressConfigured: duress }; return padPlaintext(JSON.stringify(envelope)); } function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean { if ("created" in raw || "updated" in raw) return true; const flat = raw as Partial; if (flat.type === "passkey") return true; // Flat rpId without a passkey sub-object means pre-v2.11.0 schema. if (flat.rpId !== undefined && flat.passkey === undefined) return true; return false; } function decodePayload(plain: string): DecryptedPayload { const parsed = JSON.parse(plain) as | Array | VaultPayload; const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? []; const flatMigrationNeeded = rawEntries.some(rawNeedsMigration); const normalized = rawEntries.map(normalizeEntry); const { merged } = mergePasskeyOnlyIntoLogins(normalized); if (Array.isArray(parsed)) { return { entries: normalized, duressConfigured: false, migratedFromLegacy: true, // pre-A-2 array shape always needs save }; } return { entries: normalized, duressConfigured: parsed.duressConfigured === true, migratedFromLegacy: flatMigrationNeeded || merged, }; } /** * One-time consolidation pass run alongside the legacy passkey migration. * * Pre-v2.11.0 the Chrome passkey path always created a fresh `type:"passkey"` * entry per registration — so users who tested registration multiple times * accumulated several passkey-bearing entries for the same RP, and the * migration to the unified shape carried that duplication forward. This * pass collapses the duplicates: * * 1. Group passkey-bearing (and not deleted) entries by `passkey.rpId`. * 2. Within each group, the most recently-active credential wins (prefer * lastUsedAt, fall back to passkey.createdAt, fall back to modifiedAt). * 3. If a NON-passkey login entry exists for that RP (matched by url or * additionalUrls hostname), attach the winning passkey to it and * soft-delete every passkey-bearing entry in the group. * 4. Otherwise, soft-delete every loser in the group, keeping the winner * as a passkey-only entry. * * Soft delete (deletedAt) rather than splice — the trash flow keeps the * record around for retention and the user can restore if the heuristic * picked wrong. The `merged` flag goes back to decodePayload so the * unlock path persists the consolidated shape immediately. */ function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } { let merged = false; const now = Date.now(); const candidates = entries.filter((e) => !e.deletedAt && e.passkey); const byRpId = new Map(); for (const e of candidates) { const rpId = e.passkey!.rpId; const list = byRpId.get(rpId); if (list) list.push(e); else byRpId.set(rpId, [e]); } for (const [rpId, group] of byRpId) { if (group.length === 0) continue; group.sort((a, b) => { const aT = a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt; const bT = b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt; return bT - aT; }); const winner = group[0]!; // Look for a NON-passkey login entry matching this RP. Use the URL // hostname rather than a substring search so a "login.foo.com" entry // doesn't accidentally absorb a passkey for "foo.com". const loginTarget = entries.find((e) => { if (e.deletedAt || e.passkey || e.type !== "login") return false; if (e.url && extractDomain(e.url) === rpId) return true; for (const extra of e.additionalUrls ?? []) { if (extractDomain(extra) === rpId) return true; } return false; }); if (loginTarget) { loginTarget.passkey = winner.passkey; loginTarget.modifiedAt = now; for (const dup of group) { if (!dup.deletedAt) { dup.deletedAt = now; dup.modifiedAt = now; } } merged = true; } else if (group.length > 1) { for (let i = 1; i < group.length; i += 1) { const dup = group[i]!; if (!dup.deletedAt) { dup.deletedAt = now; dup.modifiedAt = now; } } merged = true; } } return { merged }; } async function tryDecryptSlot( vault: StoredVault, key: Uint8Array ): Promise { try { const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag); return decodePayload(plain); } catch { return null; } } // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- function buildDomainIndexSync(all: VaultEntry[]): void { domainIndex = new Map(); for (const entry of all) { if (entry.deletedAt) continue; if (entry.type === "note" || entry.type === "card") continue; // An entry can match multiple sites via `additionalUrls`. Index each // hostname so `queryDomain` returns the entry regardless of which alias // the user is currently visiting. Dedupe per-entry so a single entry // never appears twice in one bucket. const domains = new Set(); const primary = extractDomain(entry.url ?? ""); if (primary) domains.add(primary); for (const extra of entry.additionalUrls ?? []) { const d = extractDomain(extra); if (d) domains.add(d); } if (domains.size === 0) continue; for (const domain of domains) { const bucket = domainIndex.get(domain) ?? []; bucket.push(entry); domainIndex.set(domain, bucket); } } } export async function createVault(password: string): Promise { await ensureMigrated(); const salt = generateSalt(); derivedKey = await deriveKey(password, salt); entries = []; vaultMode = "real"; duressConfigured = false; buildDomainIndexSync(entries); // Generate and wrap the root key for recovery word escrow. const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } = await generateAndWrapRootKey(); rootKey = unhex(rawRootKey); const plaintext = encodePayload(entries, duressConfigured); const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext); const slotA: StoredVault = { version: 1, salt: hex(salt), nonce, ciphertext, tag, recovery_salt: recoverySalt, recovery_wrapped_root_key: wrappedRootKey, recovery_nonce: wrappedNonce, recovery_tag: wrappedTag, }; await writeSlot(SLOT_A, slotA); // Matching-size noise blob for slot B, so a fresh install shows two // similarly-shaped records regardless of whether duress is ever set up. await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2)); touchAutoLock(); } export async function hasVault(): Promise { await ensureMigrated(); return (await loadSlot(SLOT_A)) !== null; } /** * Returns whether the real vault has recorded that a duress passphrase is * configured. Reliable only after a real-mode unlock — before unlock or in * decoy mode, returns false because we cannot read slot A's plaintext. */ export async function isDuressEnabled(): Promise { return vaultMode === "real" && duressConfigured; } export async function hasDecoyVault(): Promise { return isDuressEnabled(); } export async function setupDuress(duressPassword: string): Promise { if (!derivedKey || vaultMode !== "real") { throw new Error("vault must be unlocked in real mode"); } await ensureMigrated(); // Encrypt the decoy payload (empty entries) under the duress key. Pad to // at least the current slot A ciphertext size so the two slots look the // same at rest at setup time. const slotA = await loadSlot(SLOT_A); if (!slotA) throw new Error("missing slot A"); const decoySalt = generateSalt(); const decoyKey = await deriveKey(duressPassword, decoySalt); try { // Pad the decoy plaintext to match slot A's ciphertext length (minus the // 16-byte tag) so encrypted sizes align exactly. const targetBytes = slotA.ciphertext.length / 2; let plaintext = encodePayload([], false); const encoded = new TextEncoder().encode(plaintext); if (encoded.length < targetBytes) { plaintext = plaintext + " ".repeat(targetBytes - encoded.length); } const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext); await writeSlot(SLOT_B, { version: 1, salt: hex(decoySalt), nonce, ciphertext, tag, }); } finally { zeroKey(decoyKey); } // Re-encrypt slot A with the duressConfigured flag set to true. No external // flag is written — the bit lives inside the encrypted envelope. duressConfigured = true; await persistSlotA(); } export async function disableDuress(): Promise { if (!derivedKey || vaultMode !== "real") { throw new Error("vault must be unlocked in real mode"); } await ensureMigrated(); // Overwrite slot B with fresh noise that matches slot A's size. The slot // remains occupied so its mere existence does not signal that duress was // ever configured. const slotA = await loadSlot(SLOT_A); if (slotA) { await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2)); } duressConfigured = false; await persistSlotA(); } export async function verifyMainPassword(password: string): Promise { await ensureMigrated(); const slotA = await loadSlot(SLOT_A); if (!slotA) return false; const key = await deriveKey(password, unhex(slotA.salt)); try { return (await tryDecryptSlot(slotA, key)) !== null; } finally { zeroKey(key); } } export async function unlock(password: string): Promise { await ensureMigrated(); // Load both slots in parallel. Both always exist post-migration; the // parallel load and the parallel derivation below keep wall-clock time // constant regardless of which slot's passphrase (real or decoy) the // caller supplied, so a timing side channel cannot distinguish the // duress path from the real-unlock path (DUR-002). const [slotA, slotB] = await Promise.all([ loadSlot(SLOT_A), loadSlot(SLOT_B), ]); // Derive both keys in parallel, unconditionally. const [keyA, keyB] = await Promise.all([ slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null), slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null), ]); // Attempt both decrypts in parallel. const [payloadA, payloadB] = await Promise.all([ keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null), keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null), ]); if (payloadA && keyA) { derivedKey = keyA; vaultMode = "real"; entries = payloadA.entries; duressConfigured = payloadA.duressConfigured; buildDomainIndexSync(entries); await loadSettings(); touchAutoLock(); if (keyB) zeroKey(keyB); // slotA is non-null when payloadA is truthy (keyA was derived from slotA). if (slotA) { const needsRecoverySetup = !slotA.recovery_salt || !slotA.recovery_wrapped_root_key; if (!needsRecoverySetup) { rootKey = await deriveRootKeyFromDerivedKey( keyA, slotA.recovery_salt! ); } } // If the payload had a pre-v2.11.0 shape (legacy passkey type, flat // passkey fields, or pre-A-2 created/updated keys), rewrite it now so // the next save uses the new format and migration doesn't re-run forever. if (payloadA.migratedFromLegacy) { await persistSlotA(); } return true; } if (payloadB && keyB) { derivedKey = keyB; vaultMode = "decoy"; entries = payloadB.entries; duressConfigured = false; buildDomainIndexSync(entries); await loadSettings(); touchAutoLock(); if (keyA) zeroKey(keyA); return true; } if (keyA) zeroKey(keyA); if (keyB) zeroKey(keyB); return false; } export function lock(): void { if (derivedKey) { zeroKey(derivedKey); derivedKey = null; } if (rootKey) { rootKey.fill(0); rootKey = null; } entries = []; domainIndex = new Map(); vaultMode = "real"; duressConfigured = false; clearAutoLock(); } export function isUnlocked(): boolean { return derivedKey !== null; } // Opens the vault using a pre-derived AES-256 key (skipping Argon2id). // Used by Android biometric unlock — the key was derived on the native side. export async function unlockWithKey( masterKeyHex: string, ): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> { try { const masterKeyBytes = unhex(masterKeyHex); const stored = await loadSlot(SLOT_A); if (!stored) return { ok: false, error: "invalid_key" }; const payload = await tryDecryptSlot(stored, masterKeyBytes); if (!payload) return { ok: false, error: "invalid_key" }; derivedKey = masterKeyBytes; vaultMode = "real"; entries = payload.entries; duressConfigured = payload.duressConfigured; buildDomainIndexSync(entries); await loadSettings(); touchAutoLock(); return { ok: true, entries }; } catch { return { ok: false, error: "invalid_key" }; } } // Returns the current in-memory derived key as a hex string. // Used by the biometric enable flow in Settings to store the key in Android Keystore. export function getDerivedKeyHex(): string { if (!derivedKey) throw new Error("vault_not_unlocked"); return hex(derivedKey); } export function getEntries(): VaultEntry[] { if (!derivedKey) throw new Error("vault locked"); return entries.filter((entry) => !entry.deletedAt); } const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export function getIdentities(): SavedIdentity[] { if (!derivedKey) throw new Error("vault locked"); const active = entries.filter((e) => !e.deletedAt && e.type === "login"); const emailMap = new Map(); const usernameMap = new Map(); for (const entry of active) { const value = entry.username?.trim(); if (!value) continue; const map = EMAIL_RE.test(value) ? emailMap : usernameMap; const existing = map.get(value); const domain = extractDomain(entry.url ?? "") ?? ""; const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0; if (!existing || modifiedAt > existing.lastUsed) { map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain }); } else { map.set(value, { ...existing, count: existing.count + 1 }); } } const toIdentity = (type: "email" | "username") => ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({ value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined, }); return [ ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount), ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount), ]; } export async function batchAddEntries(drafts: EntryDraft[]): Promise { if (!derivedKey) throw new Error("vault locked"); const now = Date.now(); const added: VaultEntry[] = []; for (const draft of drafts) { try { const sanitized = sanitizeEntryDraft(draft); const nextEntry: VaultEntry = { ...sanitized, id: crypto.randomUUID(), createdAt: now, modifiedAt: now, }; entries.push(nextEntry); added.push(nextEntry); } catch { // Skip invalid drafts silently — caller can see diff between input and output length } } if (added.length > 0) { await persistEntries(); } return added; } /** * Restore a set of entries in full fidelity — preserves id, createdAt, * modifiedAt, and every other field unlike `batchAddEntries` which * assigns fresh identity. Used by restore paths where the backup is the * authoritative source of truth. `mode` controls how entries already in * the vault are treated. */ export async function restoreEntries( incoming: VaultEntry[], mode: "replace" | "merge" ): Promise<{ added: number; updated: number; skipped: number }> { if (!derivedKey) throw new Error("vault locked"); // A replace with zero incoming entries is almost certainly a caller bug // (e.g. an import that silently dropped everything). Refuse rather than // wipe the vault. if (mode === "replace" && incoming.length === 0) { throw new Error("refuse to replace with empty entry set"); } let added = 0; let updated = 0; let skipped = 0; if (mode === "replace") { entries = []; for (const entry of incoming) { const normalized = sanitizeRestoredEntry(entry); if (!normalized) { skipped += 1; continue; } entries.push(normalized); added += 1; } } else { const byId = new Map(); for (const e of entries) byId.set(e.id, e); for (const entry of incoming) { const normalized = sanitizeRestoredEntry(entry); if (!normalized) { skipped += 1; continue; } const existing = byId.get(normalized.id); if (!existing) { entries.push(normalized); added += 1; } else if (normalized.modifiedAt > existing.modifiedAt) { const idx = entries.findIndex((e) => e.id === existing.id); if (idx !== -1) { entries[idx] = normalized; updated += 1; } } else { skipped += 1; } } } await persistEntries("import"); return { added, updated, skipped }; } /** * Validate + normalise a VaultEntry coming from a backup. Accepts * anything structurally sound; keeps all metadata verbatim. Returns * null for values the live vault shouldn't accept (e.g., missing title). */ function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null { if (!entry || typeof entry !== "object") return null; const title = typeof entry.title === "string" ? entry.title.trim() : ""; if (!title) return null; const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID(); const now = Date.now(); const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now; const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt; // Migrate legacy passkey shape on import (same logic as on-disk read). // Imports from pre-v2.11.0 vault backups still carry type:"passkey" with // flat fields; bring them into the new shape immediately. const migrated: VaultEntry = migrateLegacyPasskey( { ...entry, id, title, type: entry.type ?? "login", createdAt, modifiedAt, }, entry ); // Login entries from backup must carry a password (or be passkey-only, // in which case the passkey sub-object replaces the password requirement). if ( migrated.type === "login" && !migrated.passkey && typeof migrated.password !== "string" ) { return null; } if (migrated.passkey && !migrated.passkey.credentialId) { return null; } return migrated; } // Soft-deletes every non-deleted entry in one call. The literal string // argument is a tripwire against accidental calls — any caller that would // wipe the vault has to consciously type "REPLACE_ALL". Do not relax this // to a boolean. export async function clearVaultEntries( confirmation: "REPLACE_ALL" ): Promise { if (confirmation !== "REPLACE_ALL") { throw new Error("clearVaultEntries requires explicit confirmation"); } if (!derivedKey) throw new Error("vault locked"); for (const entry of entries) { if (!entry.deletedAt) { entry.deletedAt = Date.now(); entry.modifiedAt = Date.now(); } } await persistEntries(); } export async function addEntry(entry: EntryDraft): Promise { if (!derivedKey) throw new Error("vault locked"); const sanitized = sanitizeEntryDraft(entry); const now = Date.now(); const nextEntry: VaultEntry = { ...sanitized, id: crypto.randomUUID(), createdAt: now, modifiedAt: now, // Stamp passwordChangedAt at creation so age tracking is accurate from // day one. Non-login entries carry the same stamp harmlessly. passwordChangedAt: sanitized.password ? now : undefined, }; entries.push(nextEntry); await persistEntries(); // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set) if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) { checkBreach(nextEntry.password).then(async (count) => { if (count > 0) { const idx = entries.findIndex((e) => e.id === nextEntry.id); if (idx !== -1) { entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() }; await persistEntries(); } } }); } return nextEntry; } export async function updateEntry( id: string, patch: Partial ): Promise { if (!derivedKey) throw new Error("vault locked"); const idx = entries.findIndex((entry) => entry.id === id); if (idx === -1 || entries[idx].deletedAt) { throw new Error("entry not found"); } const current = entries[idx]; const sanitizedPatch = sanitizeEntryPatch(patch); // Refresh passwordChangedAt only when the password value actually changes — // a metadata-only update (title, tags, etc.) must not reset the age clock // that drives the Pro health rotation recommendation. const passwordChanged = "password" in patch && typeof sanitizedPatch.password === "string" && sanitizedPatch.password !== current.password; entries[idx] = { ...current, ...sanitizedPatch, modifiedAt: Date.now(), ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}), }; // A login entry must carry SOMETHING fillable — either a password or a // passkey credential. An empty-password login with no passkey is dead // state. Permit the password-clear when a passkey is attached (that's // the legitimate "drop the password, keep the passkey" path). if ( entries[idx].type === "login" && !entries[idx].password && !entries[idx].passkey ) { entries[idx] = current; throw new Error("password or passkey required"); } // Card entries must retain their card number if (entries[idx].type === "card" && !entries[idx].cardNumber) { entries[idx] = current; throw new Error("card number is required for card entries"); } await persistEntries(); // Breach check when password changes: clear immediately so old breach doesn't linger, // then async-check the new password and only re-set if it's also found. if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") { entries[idx] = { ...entries[idx], breach: undefined }; await persistEntries(); const password = entries[idx].password; if (password) { checkBreach(password).then(async (count) => { const i = entries.findIndex((e) => e.id === id); if (i !== -1 && count > 0) { entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() }; await persistEntries(); } }); } } } export async function deleteEntry(id: string): Promise { if (!derivedKey) throw new Error("vault locked"); const entry = entries.find((item) => item.id === id); if (!entry || entry.deletedAt) { throw new Error("entry not found"); } entry.deletedAt = Date.now(); entry.modifiedAt = Date.now(); await persistEntries(); } export function getDeletedEntries(): VaultEntry[] { if (!derivedKey) return []; return entries.filter((e) => !!e.deletedAt); } export async function restoreEntry(id: string): Promise { if (!derivedKey) throw new Error("vault locked"); const entry = entries.find((e) => e.id === id && e.deletedAt); if (!entry) throw new Error("deleted entry not found"); delete entry.deletedAt; entry.modifiedAt = Date.now(); await persistEntries(); buildDomainIndexSync(entries); } export async function purgeEntry(id: string): Promise { if (!derivedKey) throw new Error("vault locked"); const idx = entries.findIndex((e) => e.id === id && e.deletedAt); if (idx === -1) throw new Error("deleted entry not found"); entries.splice(idx, 1); await persistEntries(); } /** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */ export async function purgeExpiredTrash(retentionMs: number): Promise { if (!derivedKey || retentionMs === 0) return 0; const cutoff = Date.now() - retentionMs; const before = entries.length; entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff)); const purged = before - entries.length; if (purged > 0) await persistEntries(); return purged; } export function queryDomain(domain: string): VaultEntry[] { return domainIndex.get(domain) ?? []; } export function getEntryById(id: string): VaultEntry | undefined { return entries.find((entry) => entry.id === id && !entry.deletedAt); } async function persistSlotA( reason: VaultSnapshot["reason"] = "local_save", ): Promise { if (!derivedKey) throw new Error("vault locked"); const slotA = await loadSlot(SLOT_A); if (!slotA) throw new Error("missing slot A"); // Snapshot the about-to-be-overwritten state BEFORE we mutate disk. Failures // are swallowed by takeVaultSnapshot — history must never block a save. await takeVaultSnapshot(reason); const plaintext = encodePayload(entries, duressConfigured); const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext); // Preserve existing recovery escrow fields. const nextSlotA: StoredVault = { ...slotA, nonce, ciphertext, tag, // recovery_salt and recovery_wrapped_root_key are already set from loadSlot }; await writeSlot(SLOT_A, nextSlotA); // When duress is not configured, keep slot B size-matched with slot A by // regenerating its noise. When duress IS configured, leave slot B alone — // we don't have the decoy key to re-encrypt it. This is a known trade-off. if (!duressConfigured) { await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2)); } return nextSlotA; } async function persistSlotB(): Promise { if (!derivedKey) throw new Error("vault locked"); const slotB = await loadSlot(SLOT_B); if (!slotB) throw new Error("missing slot B"); // Decoy plaintext always reports duressConfigured=false — the decoy user // sees a clean slate. const plaintext = encodePayload(entries, false); const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext); await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag }); } async function persistEntries( reason: VaultSnapshot["reason"] = "local_save", ): Promise { if (!derivedKey) throw new Error("vault locked"); if (vaultMode === "decoy") { await persistSlotB(); } else { await persistSlotA(reason); } buildDomainIndexSync(entries); } function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined { if (!Array.isArray(values)) return undefined; const seen = new Set(); const cleaned: string[] = []; for (const value of values) { const trimmed = typeof value === "string" ? value.trim() : ""; if (!trimmed) continue; const key = trimmed.toLowerCase(); if (seen.has(key)) continue; seen.add(key); cleaned.push(trimmed); } return cleaned.length > 0 ? cleaned : undefined; } function sanitizeEntryDraft(entry: EntryDraft): EntryDraft { const title = entry.title.trim(); // Accept legacy `type:"passkey"` drafts but rewrite to "login" — the new // model is "any entry, optionally with a passkey sub-object." Imports and // any in-flight callers from before v2.11.0 keep working without throwing. let type: VaultEntry["type"] = entry.type ?? "login"; if (type === "passkey") type = "login"; if (!title) throw new Error("title is required"); // A login entry needs either a password or a passkey credential to be useful. // Passkey-only entries (passkey present, no password) are valid. if (type === "login" && !entry.password?.trim() && !entry.passkey) { throw new Error("password or passkey required"); } if (entry.passkey) { const p = entry.passkey; if ( !p.rpId || !p.credentialId || !p.publicKeyCose || !p.userHandle || !p.userName || (p.algorithm !== -7 && p.algorithm !== -8) ) { throw new Error("invalid passkey credential"); } } if (type === "card") { if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) { throw new Error("cardholder name and card number are required"); } } return { type, title, username: type === "login" ? entry.username?.trim() ?? "" : undefined, password: type === "login" ? entry.password : undefined, url: type === "login" ? entry.url?.trim() ?? "" : undefined, additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined, totp: type === "login" ? entry.totp?.trim() || undefined : undefined, notes: entry.notes?.trim() || undefined, breach: type === "login" ? entry.breach : undefined, canary: type === "login" ? entry.canary : undefined, tags: entry.tags, passkey: entry.passkey, cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined, cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined, expMonth: type === "card" ? entry.expMonth?.trim() : undefined, expYear: type === "card" ? entry.expYear?.trim() : undefined, cvc: type === "card" ? entry.cvc?.trim() : undefined, cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined, network: type === "card" ? entry.network : undefined, last4: type === "card" ? entry.last4?.trim() : undefined, }; } function sanitizeEntryPatch(patch: Partial): Partial { const nextPatch: Partial = {}; if ("type" in patch) { nextPatch.type = patch.type; } if ("title" in patch) { const title = patch.title?.trim() ?? ""; if (!title) throw new Error("title is required"); nextPatch.title = title; } if ("url" in patch) { nextPatch.url = patch.url?.trim() ?? ""; } if ("additionalUrls" in patch) { const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : []; const seen = new Set(); const cleaned: string[] = []; for (const value of incoming) { const trimmed = typeof value === "string" ? value.trim() : ""; if (!trimmed) continue; const key = trimmed.toLowerCase(); if (seen.has(key)) continue; seen.add(key); cleaned.push(trimmed); } nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined; } if ("username" in patch) { nextPatch.username = patch.username?.trim() ?? ""; } if ("email" in patch) { nextPatch.email = patch.email?.trim() || undefined; } if ("password" in patch) { const password = patch.password ?? ""; // Allow clearing a password (e.g. on passkey-only entries) nextPatch.password = password || undefined; } if ("totp" in patch) { nextPatch.totp = patch.totp?.trim() || undefined; } if ("notes" in patch) { nextPatch.notes = patch.notes?.trim() || undefined; } if ("breach" in patch) { nextPatch.breach = patch.breach; } if ("canary" in patch) { nextPatch.canary = patch.canary; } if ("passkey" in patch) { // Passkey sub-object replaces all the legacy flat passkey fields. A // patch with `passkey: undefined` removes the credential from the entry // (used by "remove passkey" without deleting the whole entry). nextPatch.passkey = patch.passkey; } if ("tags" in patch) { nextPatch.tags = patch.tags; } if ("cardholderName" in patch) { nextPatch.cardholderName = patch.cardholderName?.trim() || undefined; } if ("cardNumber" in patch) { const n = patch.cardNumber ?? ""; nextPatch.cardNumber = n || undefined; } if ("expMonth" in patch) { nextPatch.expMonth = patch.expMonth?.trim() || undefined; } if ("expYear" in patch) { nextPatch.expYear = patch.expYear?.trim() || undefined; } if ("cvc" in patch) { nextPatch.cvc = patch.cvc?.trim() || undefined; } if ("cardNickname" in patch) { nextPatch.cardNickname = patch.cardNickname?.trim() || undefined; } if ("network" in patch) { nextPatch.network = patch.network; } if ("last4" in patch) { nextPatch.last4 = patch.last4?.trim() || undefined; } return nextPatch; } function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry { const createdAt = "createdAt" in entry ? entry.createdAt : entry.created; const modifiedAt = "modifiedAt" in entry ? entry.modifiedAt : entry.updated; const base: VaultEntry = { id: entry.id, type: "type" in entry ? entry.type : "login", title: entry.title, username: entry.username, password: entry.password, url: entry.url, totp: entry.totp, notes: entry.notes, breach: entry.breach, canary: "canary" in entry ? entry.canary : undefined, passkey: "passkey" in entry ? entry.passkey : undefined, createdAt, modifiedAt, deletedAt: entry.deletedAt, tags: "tags" in entry ? entry.tags : undefined, cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined, cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined, expMonth: "expMonth" in entry ? entry.expMonth : undefined, expYear: "expYear" in entry ? entry.expYear : undefined, cvc: "cvc" in entry ? entry.cvc : undefined, cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined, network: "network" in entry ? entry.network : undefined, last4: "last4" in entry ? entry.last4 : undefined, }; return migrateLegacyPasskey(base, entry); } /** * v2.11.0 read migration. Pre-v2.11.0 passkey entries used `type:"passkey"` * with flat top-level fields (rpId, credentialId, etc.). This converts them * to the new shape — `type:"login"` with a `passkey` sub-object — so all * downstream consumers can rely on the new model. The result is rewritten * to disk on next vault save (hasLegacyShape detects the un-migrated case). */ function migrateLegacyPasskey( base: VaultEntry, raw: VaultEntry | LegacyVaultEntry ): VaultEntry { if (base.passkey) return base; const flat = raw as Partial; const isPasskeyType = base.type === "passkey"; const hasFlatPasskeyFields = typeof flat.rpId === "string" && typeof flat.credentialId === "string" && typeof flat.publicKeyCose === "string" && typeof flat.userHandle === "string" && typeof flat.userName === "string"; if (!isPasskeyType && !hasFlatPasskeyFields) return base; if (!hasFlatPasskeyFields) return base; const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7; base.passkey = { rpId: flat.rpId!, rpName: flat.rpName ?? flat.rpId!, credentialId: flat.credentialId!, publicKeyCose: flat.publicKeyCose!, privateKeyJwk: flat.privateKeyJwk, userHandle: flat.userHandle!, userName: flat.userName!, userDisplayName: flat.userDisplayName ?? flat.userName!, algorithm, signCount: flat.signCount ?? 0, aaguid: flat.aaguid ?? "", backupEligible: flat.backupEligible === true, backupState: flat.backupState === true, lastUsedAt: flat.lastUsedAt, browser: flat.browser, transports: flat.transports, createdAt: base.createdAt, }; // Promote to "login" so the unified Entry view renders. Username gets // populated from the passkey's userName so the entry is searchable in the // existing autofill flow without a separate code path. if (isPasskeyType) base.type = "login"; if (!base.username) base.username = flat.userName; if (!base.url) base.url = `https://${flat.rpId}`; return base; } // --------------------------------------------------------------------------- // Deduplication // --------------------------------------------------------------------------- function entryDupeKey(e: VaultEntry): string { if (e.type === "note") return `note|${e.title.toLowerCase()}`; // Passkey-bearing entries dedupe by the credential's RP + user. Two logins // on different sites that happen to share a username are still distinct; // two passkey credentials for the same RP+user are duplicates regardless // of any login fields they may also carry. if (e.passkey) { return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`; } return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`; } /** Returns the number of duplicate entries (those that would be removed). */ export function countDuplicates(): number { const best = new Map(); let dupeCount = 0; for (const e of entries.filter((e) => !e.deletedAt)) { const key = entryDupeKey(e); const existing = best.get(key); if (!existing) { best.set(key, e); } else { dupeCount++; // Keep the more recently modified one in the map if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) { best.set(key, e); } } } return dupeCount; } /** * Soft-deletes duplicate entries, keeping the most recently modified copy of * each group. Returns the number of entries moved to trash. */ export async function deduplicateEntries(): Promise { if (!derivedKey) throw new Error("vault locked"); // Dedup reshapes slot B permanently under the decoy key, which is // forensically distinguishable from a freshly-seeded decoy vault. Refuse. if (vaultMode === "decoy") { throw new Error("deduplicate disabled in decoy mode"); } const best = new Map(); for (const e of entries.filter((en) => !en.deletedAt)) { const key = entryDupeKey(e); const existing = best.get(key); if (!existing) { best.set(key, e); } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) { best.set(key, e); } } const keepIds = new Set(Array.from(best.values()).map((e) => e.id)); const now = Date.now(); let removed = 0; entries = entries.map((e) => { if (!e.deletedAt && !keepIds.has(e.id)) { removed++; return { ...e, deletedAt: now }; } return e; }); if (removed > 0) await persistEntries(); return removed; } // --------------------------------------------------------------------------- // Vault history — snapshot BEFORE every vault write so the saved blob is // what is about to be lost, not what was just written. Capped at 10. The // stored blobs are AES-256-GCM ciphertext under the master password, so // history adds zero security surface. // --------------------------------------------------------------------------- const HISTORY_KEY = "peach_vault_history"; const HISTORY_MAX = 10; /** * Snapshot the CURRENT on-disk vault state into history. Call this BEFORE * overwriting peach_vault_a so the saved blob captures the about-to-be-lost * state. Identical-to-most-recent blobs are skipped to avoid filling history * with no-op writes. */ export async function takeVaultSnapshot( reason: VaultSnapshot["reason"], ): Promise { try { const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]); const slotA = (raw as Record)[SLOT_A]; const slotB = (raw as Record)[SLOT_B]; // Nothing to snapshot if the real slot doesn't exist yet (fresh install, // pre-createVault). createVault skips snapshots intentionally — there is // no prior state to preserve. if (!slotA) return; const vault_a = JSON.stringify(slotA); const vault_b = JSON.stringify(slotB ?? slotA); // Best-effort entry count: only meaningful when the in-memory state is // unlocked AND in real mode. Decoy mode would leak the decoy entry // count; locked mode has no entries to count. Both fall through to null. let entryCount: number | null = null; if (derivedKey && vaultMode === "real") { try { entryCount = entries.filter((e) => !e.deletedAt).length; } catch { entryCount = null; } } const existingRaw = (raw as Record)[HISTORY_KEY]; const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots) ? existingRaw : { snapshots: [] }; // Deduplication: if the most recent snapshot has byte-identical blobs, // skip — saves the same vault N times in a row would otherwise burn the // entire history window with no recovery value. const latest = history.snapshots[0]; if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) { return; } const snapshot: VaultSnapshot = { id: crypto.randomUUID(), savedAt: Date.now(), reason, vault_a, vault_b, entryCount, }; history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX); await chrome.storage.local.set({ [HISTORY_KEY]: history }); } catch { // Non-fatal — history must never block a save. A failed snapshot just // means the user loses one rung of the safety ladder for this write. } } /** Wipe all vault history. Called by Panic / DESTROY_VAULT. */ export async function clearVaultHistory(): Promise { try { await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory }); } catch { // Non-fatal — DESTROY_VAULT also runs storage.local.clear(). } } /** Read all snapshots, newest first, for the Settings vault history view. */ export async function getVaultHistory(): Promise { const raw = await chrome.storage.local.get(HISTORY_KEY); const history = (raw as Record)[HISTORY_KEY]; if (!history || !Array.isArray(history.snapshots)) return []; return history.snapshots; } /** * Replace the on-disk vault with the chosen snapshot's blobs and lock the * vault. The current state is itself snapshotted with reason 'restore' first * so the restore is reversible. After this returns, the user must unlock * with the password that matches the restored blob. */ export async function restoreVaultSnapshot( snapshotId: string, ): Promise<{ ok: true } | { ok: false; error: string }> { const history = await getVaultHistory(); const snapshot = history.find((s) => s.id === snapshotId); if (!snapshot) return { ok: false, error: "snapshot_not_found" }; // Snapshot the about-to-be-replaced current state so the restore is // reversible. takeVaultSnapshot is no-op when the most recent entry is // byte-identical, so a restore-of-a-restore stays clean. await takeVaultSnapshot("restore"); let restoredA: StoredVault; let restoredB: StoredVault; try { restoredA = JSON.parse(snapshot.vault_a) as StoredVault; restoredB = JSON.parse(snapshot.vault_b) as StoredVault; } catch { return { ok: false, error: "snapshot_corrupt" }; } await writeSlot(SLOT_A, restoredA); await writeSlot(SLOT_B, restoredB); // Lock so the user must prove they can decrypt the restored blob. If they // restored a pre-password-change snapshot they discover it immediately at // the lock screen rather than silently holding an inaccessible vault. lock(); return { ok: true }; } /** * Change the master password. Re-encrypts slot A with a fresh salt + new * derived key. Slot B (duress or noise) is left untouched when duress is * configured — it uses an independent key derived from the duress password — * and regenerated as noise when duress is not configured. */ export async function changePassword( currentPassword: string, newPassword: string ): Promise { if (!derivedKey || vaultMode !== "real") return false; // Verify current password before allowing any change const valid = await verifyMainPassword(currentPassword); if (!valid) return false; const slotA = await loadSlot(SLOT_A); if (!slotA) return false; // Derive fresh key from new password + new random salt const newSalt = generateSalt(); const newKey = await deriveKey(newPassword, newSalt); // Re-encrypt the in-memory entries with the new key const plaintext = encodePayload(entries, duressConfigured); const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext); const freshSlotA: StoredVault = { ...slotA, salt: hex(newSalt), nonce, ciphertext, tag, }; // Snapshot the OLD-key blob before overwriting it. If the user later // restores from this snapshot they will need the OLD password — the // post-restore lock surfaces that immediately. await takeVaultSnapshot("local_save"); await writeSlot(SLOT_A, freshSlotA); // Swap the in-memory key synchronously before any further await. If we // threw between the Slot A write and this swap, the next persistEntries() // would overwrite Slot A with old-key ciphertext, locking the user out. zeroKey(derivedKey); derivedKey = newKey; // Rotate the root key so the old recovery words are immediately invalidated. // After this point getRecoveryWords() returns a fresh 12-word set. const { rawRootKey: freshRawKey, recoverySalt: freshSalt, wrappedRootKey: freshWrapped, wrappedNonce: freshNonce, wrappedTag: freshTag, } = await generateAndWrapRootKey(); rootKey = unhex(freshRawKey); const slotAfterRekey = await loadSlot(SLOT_A); if (slotAfterRekey) { await writeSlot(SLOT_A, { ...slotAfterRekey, recovery_salt: freshSalt, recovery_wrapped_root_key: freshWrapped, recovery_nonce: freshNonce, recovery_tag: freshTag, }); } // Regenerate noise for slot B when duress is not in use. Runs after the // key swap so a failure here is a disposable noise regeneration, not a // lockout. if (!duressConfigured) { await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2)); } return true; }