import { deriveKey, generateSalt } from "../crypto/argon2"; import { extractDomain } from "../shared/domain"; import type { AccentColorName, LegacyVaultEntry, StoredVault, VaultEntry, } 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; 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 interface VaultSettings { accentColor?: AccentColorName; theme?: "dark" | "light"; autoLockMinutes?: number; clipboardClearSeconds?: number; lockOnClose?: boolean; breachMonitoring?: boolean; passkeyProviderEnabled?: boolean; trashRetentionDays?: number; } 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 } } 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; } // --------------------------------------------------------------------------- // Storage schema migration // --------------------------------------------------------------------------- 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. */ function generateNoiseBlob(ciphertextBytes: number): StoredVault { const salt = crypto.getRandomValues(new Uint8Array(16)); const nonce = crypto.getRandomValues(new Uint8Array(12)); const cipher = crypto.getRandomValues(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; }; function encodePayload(entries: VaultEntry[], duress: boolean): string { const envelope: VaultPayload = { entries, duressConfigured: duress }; return padPlaintext(JSON.stringify(envelope)); } function decodePayload(plain: string): DecryptedPayload { const parsed = JSON.parse(plain) as | Array | VaultPayload; if (Array.isArray(parsed)) { // Pre-A-2 plaintext: bare array of entries, no duress flag. return { entries: parsed.map(normalizeEntry), duressConfigured: false, }; } return { entries: (parsed.entries ?? []).map(normalizeEntry), duressConfigured: parsed.duressConfigured === true, }; } 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 === "passkey") continue; const domain = extractDomain(entry.url ?? ""); if (!domain) continue; 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); const plaintext = encodePayload(entries, duressConfigured); const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext); const slotA: StoredVault = { version: 1, salt: hex(salt), nonce, ciphertext, tag, }; 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); // If the payload came from the pre-envelope (legacy) shape, rewrite // it immediately so the next save uses the new format. if (hasLegacyShape(payloadA.entries)) { 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; } entries = []; domainIndex = new Map(); vaultMode = "real"; duressConfigured = false; clearAutoLock(); } export function isUnlocked(): boolean { return derivedKey !== null; } export function getEntries(): VaultEntry[] { if (!derivedKey) throw new Error("vault locked"); return entries.filter((entry) => !entry.deletedAt); } 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 the CJK phrase restore path 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(); 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 type = entry.type ?? "login"; 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; if (type === "login" && typeof entry.password !== "string") { return null; } if ( type === "passkey" && (typeof entry.rpId !== "string" || typeof entry.credentialId !== "string" || !entry.privateKeyJwk) ) { return null; } return { ...entry, id, title, type, createdAt, modifiedAt, }; } 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, }; 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]; entries[idx] = { ...current, ...sanitizeEntryPatch(patch), modifiedAt: Date.now(), }; // sanitizeEntryPatch maps an empty password to `undefined`. For login // entries that would silently produce a passwordless record — roll back // the merge and refuse. if (entries[idx].type === "login" && !entries[idx].password) { entries[idx] = current; throw new Error("password is required for login entries"); } await persistEntries(); // Fire-and-forget breach check when password changes (if monitoring enabled and entry is a login) if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) { const password = entries[idx].password!; checkBreach(password).then(async (count) => { const i = entries.findIndex((e) => e.id === id); if (i !== -1) { entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, 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(): Promise { if (!derivedKey) throw new Error("vault locked"); const slotA = await loadSlot(SLOT_A); if (!slotA) throw new Error("missing slot A"); const plaintext = encodePayload(entries, duressConfigured); const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext); const nextSlotA: StoredVault = { ...slotA, nonce, ciphertext, tag, }; 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(): Promise { if (!derivedKey) throw new Error("vault locked"); if (vaultMode === "decoy") { await persistSlotB(); } else { const freshSlotA = await persistSlotA(); // Save a snapshot of the real vault after every successful write. // Failures are silently swallowed — snapshots must never interrupt saves. const activeCount = entries.filter((e) => !e.deletedAt).length; void takeSnapshot(freshSlotA, activeCount); } buildDomainIndexSync(entries); } function sanitizeEntryDraft(entry: EntryDraft): EntryDraft { const title = entry.title.trim(); const type = entry.type ?? "login"; if (!title) throw new Error("title is required"); if (type === "login" && !entry.password?.trim()) { throw new Error("password is required"); } if (type === "passkey") { if ( !entry.rpId?.trim() || !entry.rpName?.trim() || !entry.credentialId?.trim() || !entry.publicKeyCose?.trim() || !entry.privateKeyJwk || !entry.userHandle?.trim() || !entry.userName?.trim() || !entry.userDisplayName?.trim() || (entry.algorithm !== -7 && entry.algorithm !== -8) ) { throw new Error("invalid passkey entry"); } } return { type, title, username: type === "login" ? entry.username?.trim() ?? "" : undefined, password: type === "login" ? entry.password : undefined, url: type === "login" ? entry.url?.trim() ?? "" : undefined, totp: type === "login" ? entry.totp?.trim() || undefined : undefined, notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined, breach: type === "login" ? entry.breach : undefined, canary: type === "login" ? entry.canary : undefined, rpId: type === "passkey" ? entry.rpId?.trim() : undefined, rpName: type === "passkey" ? entry.rpName?.trim() : undefined, credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined, publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined, privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined, userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined, userName: type === "passkey" ? entry.userName?.trim() : undefined, userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined, algorithm: type === "passkey" ? entry.algorithm : undefined, signCount: type === "passkey" ? entry.signCount ?? 0 : undefined, aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined, backupEligible: type === "passkey" ? entry.backupEligible === true : undefined, backupState: type === "passkey" ? entry.backupState === true : undefined, lastUsedAt: type === "passkey" ? entry.lastUsedAt : 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 ("username" in patch) { nextPatch.username = patch.username?.trim() ?? ""; } 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 ("rpId" in patch) { nextPatch.rpId = patch.rpId?.trim(); } if ("rpName" in patch) { nextPatch.rpName = patch.rpName?.trim(); } if ("credentialId" in patch) { nextPatch.credentialId = patch.credentialId?.trim(); } if ("publicKeyCose" in patch) { nextPatch.publicKeyCose = patch.publicKeyCose?.trim(); } if ("privateKeyJwk" in patch) { nextPatch.privateKeyJwk = patch.privateKeyJwk; } if ("userHandle" in patch) { nextPatch.userHandle = patch.userHandle?.trim(); } if ("userName" in patch) { nextPatch.userName = patch.userName?.trim(); } if ("userDisplayName" in patch) { nextPatch.userDisplayName = patch.userDisplayName?.trim(); } if ("algorithm" in patch) { nextPatch.algorithm = patch.algorithm; } if ("signCount" in patch) { nextPatch.signCount = patch.signCount; } if ("aaguid" in patch) { nextPatch.aaguid = patch.aaguid?.trim(); } if ("backupEligible" in patch) { nextPatch.backupEligible = patch.backupEligible; } if ("backupState" in patch) { nextPatch.backupState = patch.backupState; } if ("lastUsedAt" in patch) { nextPatch.lastUsedAt = patch.lastUsedAt; } 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; return { 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, rpId: "rpId" in entry ? entry.rpId : undefined, rpName: "rpName" in entry ? entry.rpName : undefined, credentialId: "credentialId" in entry ? entry.credentialId : undefined, publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined, privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined, userHandle: "userHandle" in entry ? entry.userHandle : undefined, userName: "userName" in entry ? entry.userName : undefined, userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined, algorithm: "algorithm" in entry ? entry.algorithm : undefined, signCount: "signCount" in entry ? entry.signCount : undefined, aaguid: "aaguid" in entry ? entry.aaguid : undefined, backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined, backupState: "backupState" in entry ? entry.backupState : undefined, lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined, createdAt, modifiedAt, deletedAt: entry.deletedAt, tags: "tags" in entry ? entry.tags : undefined, }; } function hasLegacyShape(parsed: VaultEntry[]): boolean { return parsed.some( (entry) => "created" in entry || "updated" in entry ); } // --------------------------------------------------------------------------- // Deduplication // --------------------------------------------------------------------------- function entryDupeKey(e: VaultEntry): string { if (e.type === "note") return `note|${e.title.toLowerCase()}`; if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.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 snapshot system — last 6 encrypted states, used for self-recovery // --------------------------------------------------------------------------- const SNAPSHOT_KEY = "peach_vault_snapshots"; const MAX_SNAPSHOTS = 6; interface VaultSnapshot { timestamp: number; entryCount: number; slot: StoredVault; } /** Public summary (no sensitive data) returned to the popup. */ export interface SnapshotSummary { index: number; timestamp: number; entryCount: number; } async function takeSnapshot( slot: StoredVault, entryCount: number ): Promise { try { const raw = await chrome.storage.local.get(SNAPSHOT_KEY); const existing: VaultSnapshot[] = (raw as Record)[SNAPSHOT_KEY] ?? []; const next: VaultSnapshot[] = [ { timestamp: Date.now(), entryCount, slot }, ...existing, ].slice(0, MAX_SNAPSHOTS); await chrome.storage.local.set({ [SNAPSHOT_KEY]: next }); } catch { // Non-fatal — snapshot failure must never interrupt vault saves } } export async function listSnapshots(): Promise { const raw = await chrome.storage.local.get(SNAPSHOT_KEY); const snapshots: VaultSnapshot[] = (raw as Record)[SNAPSHOT_KEY] ?? []; return snapshots.map((s, i) => ({ index: i, timestamp: s.timestamp, entryCount: s.entryCount, })); } /** * Restore a snapshot by index. The password must decrypt that snapshot * (i.e., the same master password used when the snapshot was taken). * On success the vault is unlocked in-memory with the restored entries * — no re-login required. */ export async function restoreFromSnapshot( index: number, password: string ): Promise { const raw = await chrome.storage.local.get(SNAPSHOT_KEY); const snapshots: VaultSnapshot[] = (raw as Record)[SNAPSHOT_KEY] ?? []; const snapshot = snapshots[index]; if (!snapshot) return false; // Verify the password decrypts this snapshot const key = await deriveKey(password, unhex(snapshot.slot.salt)); const payload = await tryDecryptSlot(snapshot.slot, key); if (!payload) { zeroKey(key); return false; } // Write the snapshot back to slot A. Regenerate noise for slot B. await writeSlot(SLOT_A, snapshot.slot); await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2)); // Swap in-memory state to the restored vault — caller gets immediate access if (derivedKey) zeroKey(derivedKey); derivedKey = key; entries = payload.entries; duressConfigured = payload.duressConfigured; vaultMode = "real"; buildDomainIndexSync(entries); await loadSettings(); touchAutoLock(); return 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, }; 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; // Snapshot now that in-memory key and disk are consistent. Pass the // slot content we just wrote rather than re-reading from storage so the // snapshot is immune to any microtask-coalesced read-after-write race. const activeCount = entries.filter((e) => !e.deletedAt).length; void takeSnapshot(freshSlotA, activeCount); // 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; }