/** * IndexedDB storage layer using the `idb` wrapper. * * Database: "password-vault" * - Object store "entries": stores CredentialEntry objects * - Object store "groups": stores Group objects * - Object store "meta": stores app metadata (salt, test payload, version) * * All passwords are stored as encrypted blobs (encryptedPassword field). * The encryption key is never stored — only the salt and a test payload * for password verification. */ import { openDB } from 'idb' const DB_NAME = 'password-vault' const DB_VERSION = 1 /** * Open (or create) the database. * @returns {Promise} */ async function getDb() { return openDB(DB_NAME, DB_VERSION, { upgrade(db) { // Entries store — indexed by groupId for fast group filtering if (!db.objectStoreNames.contains('entries')) { const entryStore = db.createObjectStore('entries', { keyPath: 'id' }) entryStore.createIndex('groupId', 'groupId') entryStore.createIndex('updatedAt', 'updatedAt') } // Groups store if (!db.objectStoreNames.contains('groups')) { db.createObjectStore('groups', { keyPath: 'id' }) } // Meta store — single key-value pairs for app settings if (!db.objectStoreNames.contains('meta')) { db.createObjectStore('meta', { keyPath: 'key' }) } }, }) } // ======================== // Meta (salt, test payload, version) // ======================== /** * Store the vault's salt and test payload (used for password verification). * * @param {Uint8Array} salt * @param {string} testEncrypted * @param {string} testPlaintext */ export async function saveVaultMeta(salt, testEncrypted, testPlaintext) { const db = await getDb() const tx = db.transaction('meta', 'readwrite') // Store salt as base64 let binary = '' for (let i = 0; i < salt.byteLength; i++) { binary += String.fromCharCode(salt[i]) } const saltBase64 = btoa(binary) await tx.store.put({ key: 'salt', value: saltBase64 }) await tx.store.put({ key: 'testEncrypted', value: testEncrypted }) await tx.store.put({ key: 'testPlaintext', value: testPlaintext }) await tx.store.put({ key: 'dbVersion', value: DB_VERSION }) await tx.done } /** * Load the vault's salt and test payload. * * @returns {Promise<{ salt: Uint8Array|null, testEncrypted: string|null, testPlaintext: string|null }>} */ export async function loadVaultMeta() { const db = await getDb() const tx = db.transaction('meta', 'readonly') const store = tx.store const saltRow = await store.get('salt') const testEncryptedRow = await store.get('testEncrypted') const testPlaintextRow = await store.get('testPlaintext') let salt = null if (saltRow?.value) { const binary = atob(saltRow.value) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i) } salt = bytes } return { salt, testEncrypted: testEncryptedRow?.value || null, testPlaintext: testPlaintextRow?.value || null, } } /** * Check if the vault has been initialized (has a salt stored). * @returns {Promise} */ export async function isVaultInitialized() { const meta = await loadVaultMeta() return meta.salt !== null } // ======================== // Groups // ======================== /** * @typedef {import('../models/schema.js').Group} Group */ /** * Add a group. * @param {Group} group * @returns {Promise} */ export async function addGroup(group) { const db = await getDb() await db.put('groups', group) } /** * Update a group. * @param {Group} group * @returns {Promise} */ export async function updateGroup(group) { const db = await getDb() await db.put('groups', group) } /** * Delete a group (does NOT delete associated entries — they become ungrouped). * @param {string} groupId * @returns {Promise} */ export async function deleteGroup(groupId) { const db = await getDb() await db.delete('groups', groupId) } /** * Get all groups, sorted by creation date. * @returns {Promise} */ export async function getGroups() { const db = await getDb() const tx = db.transaction('groups', 'readonly') const all = await tx.store.getAll() return all.sort((a, b) => a.createdAt.localeCompare(b.createdAt)) } /** * Get a single group by ID. * @param {string} groupId * @returns {Promise} */ export async function getGroupById(groupId) { const db = await getDb() return db.get('groups', groupId) } // ======================== // Entries // ======================== /** * @typedef {import('../models/schema.js').CredentialEntry} CredentialEntry */ /** * Add an entry. * @param {CredentialEntry} entry * @returns {Promise} */ export async function addEntry(entry) { const db = await getDb() await db.put('entries', entry) } /** * Update an entry. * @param {CredentialEntry} entry * @returns {Promise} */ export async function updateEntry(entry) { const db = await getDb() await db.put('entries', entry) } /** * Delete an entry. * @param {string} entryId * @returns {Promise} */ export async function deleteEntry(entryId) { const db = await getDb() await db.delete('entries', entryId) } /** * Get a single entry by ID. * @param {string} entryId * @returns {Promise} */ export async function getEntryById(entryId) { const db = await getDb() return db.get('entries', entryId) } /** * Get all entries. Optionally filter by groupId. * Results sorted by updatedAt descending (most recent first). * * @param {Object} [options] * @param {string} [options.groupId] - Filter by group (empty string = ungrouped) * @returns {Promise} */ export async function getEntries(options = {}) { const db = await getDb() let entries if (options.groupId !== undefined) { const index = db.transaction('entries').store.index('groupId') entries = await index.getAll(options.groupId) } else { entries = await db.getAll('entries') } return entries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) } /** * Search entries by query string (matches title, username, url, notes). * * @param {string} query * @param {Object} [options] * @param {string} [options.groupId] * @returns {Promise} */ export async function searchEntries(query, options = {}) { const entries = await getEntries(options) const lower = query.toLowerCase() return entries.filter(e => e.title.toLowerCase().includes(lower) || e.username.toLowerCase().includes(lower) || (e.url && e.url.toLowerCase().includes(lower)) || (e.notes && e.notes.toLowerCase().includes(lower)) ) } /** * Count entries per group. * @returns {Promise>} */ export async function getEntryCountsByGroup() { const db = await getDb() const all = await db.getAll('entries') const counts = new Map() for (const entry of all) { const gid = entry.groupId || '' counts.set(gid, (counts.get(gid) || 0) + 1) } return counts } // ======================== // Import / Export // ======================== /** * Export all data (entries + groups + meta) as a JSON object. * Entries remain encrypted — the importer needs the same master password. * * @returns {Promise} */ export async function exportAll() { const db = await getDb() const entries = await db.getAll('entries') const groups = await db.getAll('groups') const saltRow = await db.get('meta', 'salt') const testEncryptedRow = await db.get('meta', 'testEncrypted') const testPlaintextRow = await db.get('meta', 'testPlaintext') return { version: DB_VERSION, exportedAt: new Date().toISOString(), meta: { salt: saltRow?.value || null, testEncrypted: testEncryptedRow?.value || null, testPlaintext: testPlaintextRow?.value || null, }, groups, entries, } } /** * Import data from a previously exported JSON object. * * @param {Object} data * @param {'merge'|'replace'} mode - 'merge' adds to existing, 'replace' clears first * @returns {Promise<{ imported: { entries: number, groups: number }, skipped: number }>} */ export async function importAll(data, mode = 'merge') { if (!data || !Array.isArray(data.entries) || !Array.isArray(data.groups)) { throw new Error('Invalid import data format') } const db = await getDb() if (mode === 'replace') { await db.clear('entries') await db.clear('groups') // Clear meta so user can re-setup await db.clear('meta') } let skipped = 0 let importedEntries = 0 let importedGroups = 0 // Import groups for (const group of data.groups) { try { await db.put('groups', group) importedGroups++ } catch { skipped++ } } // Import entries for (const entry of data.entries) { try { await db.put('entries', entry) importedEntries++ } catch { skipped++ } } // Restore meta if present if (data.meta?.salt) { await db.put('meta', { key: 'salt', value: data.meta.salt }) } if (data.meta?.testEncrypted) { await db.put('meta', { key: 'testEncrypted', value: data.meta.testEncrypted }) } if (data.meta?.testPlaintext) { await db.put('meta', { key: 'testPlaintext', value: data.meta.testPlaintext }) } return { imported: { entries: importedEntries, groups: importedGroups }, skipped, } }