508 lines
14 KiB
JavaScript

/**
* 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<IDBPDatabase>}
*/
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<boolean>}
*/
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<void>}
*/
export async function addGroup(group) {
const db = await getDb()
await db.put('groups', group)
}
/**
* Update a group.
* @param {Group} group
* @returns {Promise<void>}
*/
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<void>}
*/
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<void>}
*/
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<Group[]>}
*/
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<void>}
*/
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>} 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<void>}
*/
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<Group | undefined>}
*/
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<void>}
*/
export async function addEntry(entry) {
const db = await getDb()
await db.put('entries', entry)
}
/**
* Update an entry.
* @param {CredentialEntry} entry
* @returns {Promise<void>}
*/
export async function updateEntry(entry) {
const db = await getDb()
await db.put('entries', entry)
}
/**
* Delete an entry.
* @param {string} entryId
* @returns {Promise<void>}
*/
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<CredentialEntry | undefined>}
*/
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<CredentialEntry[]>}
*/
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<CredentialEntry[]>}
*/
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<void>}
*/
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<Object>}
*/
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,
}
}