508 lines
14 KiB
JavaScript
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,
|
|
}
|
|
}
|