/** * 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' import { deriveKey, decrypt, encrypt, base64ToUint8Array } from '../crypto/crypto.js' import { TRASH_GROUP_ID, createTrashGroup, isTrashGroup } from '../models/schema.js' // Re-export for convenience export { TRASH_GROUP_ID } 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 } // ======================== // Settings (user preferences in meta store) // ======================== /** * Save a single setting as a key/value pair in the meta store. * @param {string} key * @param {*} value */ export async function saveSetting(key, value) { const db = await getDb() await db.put('meta', { key: 'setting:' + key, value }) } /** * Load a single setting from the meta store. * @param {string} key * @returns {Promise<*>} The value, or undefined if not set */ export async function getSetting(key) { const db = await getDb() const row = await db.get('meta', 'setting:' + key) return row?.value ?? undefined } // ======================== // 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) { // Prevent deleting the Trash group if (isTrashGroup(groupId)) { throw new Error('Cannot delete the Trash group') } const db = await getDb() await db.delete('groups', groupId) } /** * Ensure the Trash group exists in the database. * @returns {Promise} */ export async function ensureTrashGroup() { const db = await getDb() const existing = await db.get('groups', TRASH_GROUP_ID) if (!existing) { await db.put('groups', createTrashGroup()) } } /** * 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)) } // ======================== // Trash operations // ======================== /** * Move an entry to the Trash group. * @param {string} entryId * @returns {Promise} */ export async function moveToTrash(entryId) { await ensureTrashGroup() const db = await getDb() const entry = await db.get('entries', entryId) if (!entry) throw new Error('Entry not found') entry.groupId = TRASH_GROUP_ID entry.updatedAt = new Date().toISOString() await db.put('entries', entry) } /** * Permanently delete all entries in the Trash group. * @returns {Promise} Number of entries deleted */ export async function emptyTrash() { const db = await getDb() const index = db.transaction('entries').store.index('groupId') const trashed = await index.getAll(TRASH_GROUP_ID) const tx = db.transaction('entries', 'readwrite') for (const entry of trashed) { await tx.store.delete(entry.id) } await tx.done return trashed.length } /** * Restore a trashed entry to its original group (or ungrouped if unknown). * @param {string} entryId * @param {string} [restoreGroupId] - Group to restore to (default: empty/ungrouped) * @returns {Promise} */ export async function restoreEntry(entryId, restoreGroupId = '') { const db = await getDb() const entry = await db.get('entries', entryId) if (!entry) throw new Error('Entry not found') entry.groupId = restoreGroupId entry.updatedAt = new Date().toISOString() await db.put('entries', entry) } /** * 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)) ) } /** * Move an entry to a different group (or ungroup it by passing empty string). * @param {string} entryId * @param {string} groupId * @returns {Promise} */ export async function moveEntryToGroup(entryId, groupId) { const db = await getDb() const entry = await db.get('entries', entryId) if (!entry) throw new Error('Entry not found') entry.groupId = groupId entry.updatedAt = new Date().toISOString() await db.put('entries', entry) } // ======================== // Import / Export // ======================== /** * Export data (entries + groups + meta) as a JSON object. * Entries remain encrypted with the source vault's key. The import function * requires the source vault's master password to decrypt and re-encrypt * entries under the target vault's key. * * @param {string[]} [groupIds] - Array of group IDs to export. If null/empty, exports everything. * Include '' to export ungrouped entries. * @returns {Promise} */ export async function exportSelected(groupIds) { const db = await getDb() const allEntries = await db.getAll('entries') const allGroups = 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') // If no groups selected, export everything if (!groupIds || groupIds.length === 0) { return { version: DB_VERSION, exportedAt: new Date().toISOString(), meta: { salt: saltRow?.value || null, testEncrypted: testEncryptedRow?.value || null, testPlaintext: testPlaintextRow?.value || null, }, groups: allGroups, entries: allEntries, } } const entries = allEntries.filter(e => groupIds.includes(e.groupId)) const groups = allGroups.filter(g => groupIds.includes(g.id)) 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. * * Requires the source vault's master password to decrypt entries, then * re-encrypts them under the target vault's current encryption key. * The target vault's meta (salt, test payload) is never overwritten. * * @param {Object} data * @param {'merge'|'replace'} mode - 'merge' adds to existing, 'replace' clears first * @param {string} sourcePassword - Master password of the source vault * @param {CryptoKey} targetKey - Current encryption key of the target vault * @returns {Promise<{ imported: { entries: number, groups: number }, skipped: number }>} */ export async function importAll(data, mode = 'merge', sourcePassword = '', targetKey = null) { if (!data || !Array.isArray(data.entries) || !Array.isArray(data.groups)) { throw new Error('Invalid import data format') } // Derive the source vault's key from its salt + password let sourceKey = null if (data.meta?.salt && sourcePassword) { const sourceSalt = base64ToUint8Array(data.meta.salt) sourceKey = await deriveKey(sourcePassword, sourceSalt) } const db = await getDb() if (mode === 'replace') { await db.clear('entries') await db.clear('groups') // Do NOT clear meta — preserve the target vault's identity } let skipped = 0 let importedEntries = 0 let importedGroups = 0 // Import groups (groups are not encrypted) for (const group of data.groups) { try { await db.put('groups', group) importedGroups++ } catch { skipped++ } } // Import entries — decrypt with source key, re-encrypt with target key for (const entry of data.entries) { try { let reencryptedEntry = { ...entry } if (sourceKey && targetKey && entry.encryptedPassword) { // Decrypt password with source key const plaintext = await decrypt(entry.encryptedPassword, sourceKey) // Re-encrypt under target vault's key reencryptedEntry.encryptedPassword = await encrypt(plaintext, targetKey) } else if (!sourceKey || !targetKey) { // Can't re-encrypt — skip this entry with a warning console.warn('Skipping entry (missing source password or target key):', entry.title) skipped++ continue } await db.put('entries', reencryptedEntry) importedEntries++ } catch (e) { console.warn('Failed to import entry:', entry.title, e) skipped++ } } // Never overwrite the target vault's meta return { imported: { entries: importedEntries, groups: importedGroups }, skipped, } }