2026-05-11 22:32:05 +00:00

371 lines
9.3 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'
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
}
// ========================
// 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) {
const db = await getDb()
await db.delete('groups', groupId)
}
/**
* 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))
}
/**
* 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))
)
}
/**
* Count entries per group.
* @returns {Promise<Map<string, number>>}
*/
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<Object>}
*/
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,
}
}