import { eq, and, count, inArray, type Column } from 'drizzle-orm' import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' import { vaultConfig, vaultCategories, vaultCategoryPermissions, vaultEntries } from '../db/schema/vault.js' import { userRoles } from '../db/schema/rbac.js' import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../utils/pagination.js' import type { PaginationInput } from '@lunarfront/shared/schemas' import { randomBytes, createCipheriv, createDecipheriv, pbkdf2Sync } from 'crypto' import bcrypt from 'bcryptjs' // --- Encryption key held in memory --- let derivedKey: Buffer | null = null const PBKDF2_ITERATIONS = 100_000 const KEY_LENGTH = 32 // 256 bits for AES-256 const ALGORITHM = 'aes-256-gcm' const ACCESS_RANK: Record = { traverse: 0, view: 1, edit: 2, admin: 3 } // --- Key Service --- export const VaultKeyService = { isUnlocked(): boolean { return derivedKey !== null }, async isInitialized(db: PostgresJsDatabase): Promise { const [config] = await db.select({ id: vaultConfig.id }).from(vaultConfig).limit(1) return !!config }, async initialize(db: PostgresJsDatabase, masterPassword: string): Promise { const existing = await this.isInitialized(db) if (existing) throw new Error('Vault is already initialized') const salt = randomBytes(32).toString('hex') const hash = await bcrypt.hash(masterPassword, 10) derivedKey = pbkdf2Sync(masterPassword, salt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha512') await db.insert(vaultConfig).values({ masterKeySalt: salt, masterKeyHash: hash, }) }, async unlock(db: PostgresJsDatabase, masterPassword: string): Promise { const [config] = await db.select().from(vaultConfig).limit(1) if (!config) throw new Error('Vault is not initialized') const valid = await bcrypt.compare(masterPassword, config.masterKeyHash) if (!valid) return false derivedKey = pbkdf2Sync(masterPassword, config.masterKeySalt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha512') return true }, lock(): void { derivedKey = null }, async changeMasterPassword(db: PostgresJsDatabase, currentPassword: string, newPassword: string): Promise { if (!derivedKey) throw new Error('Vault is locked') const [config] = await db.select().from(vaultConfig).limit(1) if (!config) throw new Error('Vault is not initialized') const valid = await bcrypt.compare(currentPassword, config.masterKeyHash) if (!valid) throw new Error('Current password is incorrect') // Generate new key material const newSalt = randomBytes(32).toString('hex') const newHash = await bcrypt.hash(newPassword, 10) const newKey = pbkdf2Sync(newPassword, newSalt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha512') // Re-encrypt all entries const entries = await db.select().from(vaultEntries) for (const entry of entries) { if (entry.encryptedValue && entry.iv) { // Decrypt with old key const plaintext = decrypt(entry.encryptedValue, entry.iv) // Encrypt with new key const { ciphertext, iv } = encryptWith(plaintext, newKey) await db.update(vaultEntries).set({ encryptedValue: ciphertext, iv, updatedAt: new Date() }).where(eq(vaultEntries.id, entry.id)) } } // Update config await db.update(vaultConfig).set({ masterKeySalt: newSalt, masterKeyHash: newHash, updatedAt: new Date(), }).where(eq(vaultConfig.id, config.id)) derivedKey = newKey }, } // --- Encryption helpers --- function encrypt(plaintext: string): { ciphertext: string; iv: string } { if (!derivedKey) throw new Error('Vault is locked') return encryptWith(plaintext, derivedKey) } function encryptWith(plaintext: string, key: Buffer): { ciphertext: string; iv: string } { const iv = randomBytes(12) const cipher = createCipheriv(ALGORITHM, key, iv) let encrypted = cipher.update(plaintext, 'utf8', 'hex') encrypted += cipher.final('hex') const authTag = cipher.getAuthTag().toString('hex') return { ciphertext: encrypted + ':' + authTag, iv: iv.toString('hex'), } } function decrypt(ciphertext: string, iv: string): string { if (!derivedKey) throw new Error('Vault is locked') const [encrypted, authTag] = ciphertext.split(':') const decipher = createDecipheriv(ALGORITHM, derivedKey, Buffer.from(iv, 'hex')) decipher.setAuthTag(Buffer.from(authTag, 'hex')) let decrypted = decipher.update(encrypted, 'hex', 'utf8') decrypted += decipher.final('utf8') return decrypted } // --- Permission Service --- export const VaultPermissionService = { async hasAccess(db: PostgresJsDatabase, categoryId: string, userId: string, minLevel: 'traverse' | 'view' | 'edit' | 'admin' = 'view'): Promise { const level = await this.getAccessLevel(db, categoryId, userId) if (!level) return false return ACCESS_RANK[level] >= ACCESS_RANK[minLevel] }, async getAccessLevel(db: PostgresJsDatabase, categoryId: string, userId: string): Promise<'admin' | 'edit' | 'view' | 'traverse' | null> { const [category] = await db.select({ isPublic: vaultCategories.isPublic, createdBy: vaultCategories.createdBy }) .from(vaultCategories).where(eq(vaultCategories.id, categoryId)).limit(1) if (!category) return null // Creator always has admin if (category.createdBy === userId) return 'admin' // Check direct user permission const [userPerm] = await db.select({ accessLevel: vaultCategoryPermissions.accessLevel }).from(vaultCategoryPermissions) .where(and(eq(vaultCategoryPermissions.categoryId, categoryId), eq(vaultCategoryPermissions.userId, userId))) .limit(1) if (userPerm) return userPerm.accessLevel // Check role-based permissions — pick highest across all roles const userRoleRows = await db.select({ roleId: userRoles.roleId }).from(userRoles).where(eq(userRoles.userId, userId)) if (userRoleRows.length > 0) { const roleIds = userRoleRows.map((r) => r.roleId) const rolePerms = await db.select({ accessLevel: vaultCategoryPermissions.accessLevel }).from(vaultCategoryPermissions) .where(and(eq(vaultCategoryPermissions.categoryId, categoryId), inArray(vaultCategoryPermissions.roleId, roleIds))) let best: string | null = null for (const rp of rolePerms) { if (!best || ACCESS_RANK[rp.accessLevel] > ACCESS_RANK[best]) best = rp.accessLevel } if (best) return best as any } // Public categories give view access if (category.isPublic) return 'view' return null }, async listPermissions(db: PostgresJsDatabase, categoryId: string) { return db.select().from(vaultCategoryPermissions).where(eq(vaultCategoryPermissions.categoryId, categoryId)) }, async getPermissionById(db: PostgresJsDatabase, permissionId: string) { const [perm] = await db.select().from(vaultCategoryPermissions).where(eq(vaultCategoryPermissions.id, permissionId)).limit(1) return perm ?? null }, async setPermission(db: PostgresJsDatabase, categoryId: string, roleId: string | undefined, userId: string | undefined, accessLevel: string) { if (roleId) { await db.delete(vaultCategoryPermissions).where(and(eq(vaultCategoryPermissions.categoryId, categoryId), eq(vaultCategoryPermissions.roleId, roleId))) } if (userId) { await db.delete(vaultCategoryPermissions).where(and(eq(vaultCategoryPermissions.categoryId, categoryId), eq(vaultCategoryPermissions.userId, userId))) } const [perm] = await db.insert(vaultCategoryPermissions).values({ categoryId, roleId: roleId ?? null, userId: userId ?? null, accessLevel: accessLevel as any, }).returning() return perm }, async removePermission(db: PostgresJsDatabase, permissionId: string) { const [perm] = await db.delete(vaultCategoryPermissions).where(eq(vaultCategoryPermissions.id, permissionId)).returning() return perm ?? null }, } // --- Category Service --- export const VaultCategoryService = { async create(db: PostgresJsDatabase, input: { name: string; description?: string; isPublic?: boolean; createdBy?: string }) { const [category] = await db.insert(vaultCategories).values({ name: input.name, description: input.description ?? null, isPublic: input.isPublic ?? false, createdBy: input.createdBy ?? null, }).returning() return category }, async getById(db: PostgresJsDatabase, id: string) { const [category] = await db.select().from(vaultCategories).where(eq(vaultCategories.id, id)).limit(1) return category ?? null }, async listAccessible(db: PostgresJsDatabase, userId: string) { const all = await db.select().from(vaultCategories).orderBy(vaultCategories.name) const accessible = [] for (const cat of all) { const level = await VaultPermissionService.getAccessLevel(db, cat.id, userId) if (level) accessible.push(cat) } return accessible }, async update(db: PostgresJsDatabase, id: string, input: { name?: string; description?: string; isPublic?: boolean }) { const [category] = await db.update(vaultCategories).set({ ...input, updatedAt: new Date() }).where(eq(vaultCategories.id, id)).returning() return category ?? null }, async delete(db: PostgresJsDatabase, id: string) { const [category] = await db.delete(vaultCategories).where(eq(vaultCategories.id, id)).returning() return category ?? null }, } // --- Entry Service --- export const VaultEntryService = { async create(db: PostgresJsDatabase, input: { categoryId: string; name: string; username?: string; url?: string; notes?: string; createdBy?: string }, secret?: string) { let encryptedValue: string | null = null let iv: string | null = null if (secret) { const encrypted = encrypt(secret) encryptedValue = encrypted.ciphertext iv = encrypted.iv } const [entry] = await db.insert(vaultEntries).values({ categoryId: input.categoryId, name: input.name, username: input.username ?? null, url: input.url ?? null, notes: input.notes ?? null, encryptedValue, iv, createdBy: input.createdBy ?? null, updatedBy: input.createdBy ?? null, }).returning() return entry }, async getById(db: PostgresJsDatabase, id: string) { const [entry] = await db.select().from(vaultEntries).where(eq(vaultEntries.id, id)).limit(1) return entry ?? null }, async listByCategory(db: PostgresJsDatabase, categoryId: string, params: PaginationInput) { const baseWhere = eq(vaultEntries.categoryId, categoryId) const searchCondition = params.q ? buildSearchCondition(params.q, [vaultEntries.name, vaultEntries.username]) : undefined const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere const sortableColumns: Record = { name: vaultEntries.name, username: vaultEntries.username, created_at: vaultEntries.createdAt, updated_at: vaultEntries.updatedAt, } let query = db.select({ id: vaultEntries.id, categoryId: vaultEntries.categoryId, name: vaultEntries.name, username: vaultEntries.username, url: vaultEntries.url, notes: vaultEntries.notes, createdBy: vaultEntries.createdBy, updatedBy: vaultEntries.updatedBy, createdAt: vaultEntries.createdAt, updatedAt: vaultEntries.updatedAt, // Expose whether a secret is set, but never the value hasSecret: vaultEntries.encryptedValue, }).from(vaultEntries).where(where).$dynamic() query = withSort(query, params.sort, params.order, sortableColumns, vaultEntries.name) query = withPagination(query, params.page, params.limit) const [data, [{ total }]] = await Promise.all([ query, db.select({ total: count() }).from(vaultEntries).where(where), ]) // Transform hasSecret from encrypted value to boolean const sanitized = data.map((e) => ({ ...e, hasSecret: !!e.hasSecret })) return paginatedResponse(sanitized, total, params.page, params.limit) }, async reveal(id: string, db: PostgresJsDatabase): Promise { if (!VaultKeyService.isUnlocked()) throw new Error('Vault is locked') const [entry] = await db.select({ encryptedValue: vaultEntries.encryptedValue, iv: vaultEntries.iv }) .from(vaultEntries).where(eq(vaultEntries.id, id)).limit(1) if (!entry?.encryptedValue || !entry.iv) return null return decrypt(entry.encryptedValue, entry.iv) }, async update(db: PostgresJsDatabase, id: string, input: { name?: string; username?: string; url?: string; notes?: string; updatedBy?: string }, newSecret?: string) { const updates: Record = { ...input, updatedAt: new Date() } if (newSecret !== undefined) { if (newSecret) { const encrypted = encrypt(newSecret) updates.encryptedValue = encrypted.ciphertext updates.iv = encrypted.iv } else { updates.encryptedValue = null updates.iv = null } } const [entry] = await db.update(vaultEntries).set(updates).where(eq(vaultEntries.id, id)).returning() return entry ?? null }, async delete(db: PostgresJsDatabase, id: string) { const [entry] = await db.delete(vaultEntries).where(eq(vaultEntries.id, id)).returning() return entry ?? null }, async search(db: PostgresJsDatabase, query: string, params: PaginationInput) { const searchCondition = buildSearchCondition(query, [vaultEntries.name, vaultEntries.username]) if (!searchCondition) return paginatedResponse([], 0, params.page, params.limit) let q = db.select({ id: vaultEntries.id, categoryId: vaultEntries.categoryId, name: vaultEntries.name, username: vaultEntries.username, url: vaultEntries.url, notes: vaultEntries.notes, createdBy: vaultEntries.createdBy, updatedBy: vaultEntries.updatedBy, createdAt: vaultEntries.createdAt, updatedAt: vaultEntries.updatedAt, hasSecret: vaultEntries.encryptedValue, }).from(vaultEntries).where(searchCondition).$dynamic() q = withPagination(q, params.page, params.limit) const [data, [{ total }]] = await Promise.all([ q, db.select({ total: count() }).from(vaultEntries).where(searchCondition), ]) const sanitized = data.map((e) => ({ ...e, hasSecret: !!e.hasSecret })) return paginatedResponse(sanitized, total, params.page, params.limit) }, }