Add vault secret manager backend with AES-256-GCM encryption

Secrets are encrypted at rest in the database. The derived encryption
key is held in memory only — on reboot, an authorized user must enter
the master password to unlock. Admins can also manually lock the vault.

- vault_config, vault_category, vault_category_permission, vault_entry tables
- AES-256-GCM encryption with PBKDF2-derived key + per-entry IV
- Master password initialize/unlock/lock/change lifecycle
- Category CRUD with role/user permission model (view/edit/admin)
- Entry CRUD with reveal endpoint (POST to avoid caching)
- Secret values never returned in list/detail responses
- vault.view/edit/admin RBAC permissions seeded
- 19 API integration tests covering full lifecycle
This commit is contained in:
Ryan Moon
2026-03-30 06:11:33 -05:00
parent 748ea59c80
commit 7246587955
8 changed files with 993 additions and 0 deletions

View File

@@ -0,0 +1,232 @@
import { suite } from '../lib/context.js'
const MASTER_PASSWORD = 'test-vault-master-2024!'
suite('Vault', { tags: ['vault'] }, (t) => {
// --- Initialization & Unlock ---
t.test('initializes vault with master password', { tags: ['init'] }, async () => {
const res = await t.api.post('/v1/vault/initialize', { masterPassword: MASTER_PASSWORD })
t.assert.status(res, 201)
t.assert.contains(res.data.message, 'initialized')
})
t.test('rejects re-initialization', { tags: ['init'] }, async () => {
const res = await t.api.post('/v1/vault/initialize', { masterPassword: 'another-password' })
t.assert.status(res, 400)
})
t.test('reports vault status', { tags: ['status'] }, async () => {
const res = await t.api.get('/v1/vault/status')
t.assert.status(res, 200)
t.assert.equal(res.data.initialized, true)
t.assert.equal(res.data.unlocked, true)
})
t.test('locks vault', { tags: ['lock'] }, async () => {
const res = await t.api.post('/v1/vault/lock')
t.assert.status(res, 200)
const status = await t.api.get('/v1/vault/status')
t.assert.equal(status.data.unlocked, false)
})
t.test('rejects operations when locked', { tags: ['lock'] }, async () => {
const res = await t.api.get('/v1/vault/categories')
t.assert.status(res, 403)
t.assert.contains(res.data.error.message, 'locked')
})
t.test('unlocks vault with correct password', { tags: ['lock'] }, async () => {
const res = await t.api.post('/v1/vault/unlock', { masterPassword: MASTER_PASSWORD })
t.assert.status(res, 200)
const status = await t.api.get('/v1/vault/status')
t.assert.equal(status.data.unlocked, true)
})
t.test('rejects unlock with wrong password', { tags: ['lock'] }, async () => {
await t.api.post('/v1/vault/lock')
const res = await t.api.post('/v1/vault/unlock', { masterPassword: 'wrong-password' })
t.assert.status(res, 401)
// Re-unlock for remaining tests
await t.api.post('/v1/vault/unlock', { masterPassword: MASTER_PASSWORD })
})
// --- Categories ---
t.test('creates a category', { tags: ['category'] }, async () => {
const res = await t.api.post('/v1/vault/categories', { name: 'WiFi Passwords', description: 'Store WiFi credentials' })
t.assert.status(res, 201)
t.assert.equal(res.data.name, 'WiFi Passwords')
t.assert.ok(res.data.id)
})
t.test('lists accessible categories', { tags: ['category'] }, async () => {
const res = await t.api.get('/v1/vault/categories')
t.assert.status(res, 200)
t.assert.greaterThan(res.data.data.length, 0)
})
t.test('gets category detail with accessLevel', { tags: ['category'] }, async () => {
const list = await t.api.get('/v1/vault/categories')
const catId = list.data.data[0].id
const res = await t.api.get(`/v1/vault/categories/${catId}`)
t.assert.status(res, 200)
t.assert.equal(res.data.accessLevel, 'admin')
})
t.test('updates a category', { tags: ['category'] }, async () => {
const list = await t.api.get('/v1/vault/categories')
const catId = list.data.data[0].id
const res = await t.api.patch(`/v1/vault/categories/${catId}`, { name: 'WiFi & Network' })
t.assert.status(res, 200)
t.assert.equal(res.data.name, 'WiFi & Network')
})
// --- Entries ---
t.test('creates an entry with a secret', { tags: ['entry'] }, async () => {
const list = await t.api.get('/v1/vault/categories')
const catId = list.data.data[0].id
const res = await t.api.post(`/v1/vault/categories/${catId}/entries`, {
name: 'Store WiFi',
username: 'ForteMusic',
url: 'http://192.168.1.1',
notes: 'Router admin panel',
secret: 'supersecretpassword123',
})
t.assert.status(res, 201)
t.assert.equal(res.data.name, 'Store WiFi')
t.assert.equal(res.data.username, 'ForteMusic')
t.assert.equal(res.data.hasSecret, true)
// Secret value should NOT be in the response
t.assert.falsy(res.data.encryptedValue)
t.assert.falsy(res.data.iv)
t.assert.falsy(res.data.secret)
})
t.test('lists entries without revealing secrets', { tags: ['entry'] }, async () => {
const cats = await t.api.get('/v1/vault/categories')
const catId = cats.data.data[0].id
const res = await t.api.get(`/v1/vault/categories/${catId}/entries`)
t.assert.status(res, 200)
t.assert.greaterThan(res.data.data.length, 0)
t.assert.equal(res.data.data[0].hasSecret, true)
t.assert.falsy(res.data.data[0].encryptedValue)
})
t.test('reveals a secret', { tags: ['entry', 'reveal'] }, async () => {
const cats = await t.api.get('/v1/vault/categories')
const catId = cats.data.data[0].id
const entries = await t.api.get(`/v1/vault/categories/${catId}/entries`)
const entryId = entries.data.data[0].id
const res = await t.api.post(`/v1/vault/entries/${entryId}/reveal`)
t.assert.status(res, 200)
t.assert.equal(res.data.value, 'supersecretpassword123')
})
t.test('updates an entry with new secret', { tags: ['entry'] }, async () => {
const cats = await t.api.get('/v1/vault/categories')
const catId = cats.data.data[0].id
const entries = await t.api.get(`/v1/vault/categories/${catId}/entries`)
const entryId = entries.data.data[0].id
const res = await t.api.patch(`/v1/vault/entries/${entryId}`, {
name: 'Store WiFi (updated)',
secret: 'newsecret456',
})
t.assert.status(res, 200)
t.assert.equal(res.data.name, 'Store WiFi (updated)')
// Verify new secret
const reveal = await t.api.post(`/v1/vault/entries/${entryId}/reveal`)
t.assert.equal(reveal.data.value, 'newsecret456')
})
t.test('creates entry without secret', { tags: ['entry'] }, async () => {
const cats = await t.api.get('/v1/vault/categories')
const catId = cats.data.data[0].id
const res = await t.api.post(`/v1/vault/categories/${catId}/entries`, {
name: 'Vendor Contact',
notes: 'Call 555-1234',
})
t.assert.status(res, 201)
t.assert.equal(res.data.hasSecret, false)
})
t.test('deletes an entry', { tags: ['entry'] }, async () => {
const cats = await t.api.get('/v1/vault/categories')
const catId = cats.data.data[0].id
// Create one to delete
const created = await t.api.post(`/v1/vault/categories/${catId}/entries`, {
name: 'To Delete',
secret: 'deleteme',
})
const res = await t.api.del(`/v1/vault/entries/${created.data.id}`)
t.assert.status(res, 200)
const check = await t.api.get(`/v1/vault/entries/${created.data.id}`)
t.assert.status(check, 404)
})
// --- Master Password Change ---
t.test('changes master password and secrets still work', { tags: ['master'] }, async () => {
const newMaster = 'new-master-password-2024!'
const res = await t.api.post('/v1/vault/change-master-password', {
currentPassword: MASTER_PASSWORD,
newPassword: newMaster,
})
t.assert.status(res, 200)
// Verify existing secrets still decrypt
const cats = await t.api.get('/v1/vault/categories')
const catId = cats.data.data[0].id
const entries = await t.api.get(`/v1/vault/categories/${catId}/entries`)
const secretEntry = entries.data.data.find((e: any) => e.hasSecret)
if (secretEntry) {
const reveal = await t.api.post(`/v1/vault/entries/${secretEntry.id}/reveal`)
t.assert.status(reveal, 200)
t.assert.ok(reveal.data.value)
}
// Lock and re-unlock with new password
await t.api.post('/v1/vault/lock')
const unlock = await t.api.post('/v1/vault/unlock', { masterPassword: newMaster })
t.assert.status(unlock, 200)
// Old password should fail
await t.api.post('/v1/vault/lock')
const oldUnlock = await t.api.post('/v1/vault/unlock', { masterPassword: MASTER_PASSWORD })
t.assert.status(oldUnlock, 401)
// Re-unlock with new password for cleanup
await t.api.post('/v1/vault/unlock', { masterPassword: newMaster })
})
// --- Delete category ---
t.test('deletes a category and cascades entries', { tags: ['category'] }, async () => {
// Create a fresh category with an entry
const cat = await t.api.post('/v1/vault/categories', { name: 'To Delete Cat' })
await t.api.post(`/v1/vault/categories/${cat.data.id}/entries`, { name: 'Temp Entry', secret: 'temp' })
const res = await t.api.del(`/v1/vault/categories/${cat.data.id}`)
t.assert.status(res, 200)
const check = await t.api.get(`/v1/vault/categories/${cat.data.id}`)
t.assert.status(check, 404)
})
})

View File

@@ -0,0 +1,46 @@
-- Vault secret manager tables
CREATE TABLE vault_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
master_key_salt TEXT NOT NULL,
master_key_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE vault_category (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
created_by UUID REFERENCES "user"(id),
is_public BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE vault_category_permission (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
category_id UUID NOT NULL REFERENCES vault_category(id) ON DELETE CASCADE,
role_id UUID REFERENCES role(id),
user_id UUID REFERENCES "user"(id),
access_level storage_folder_access NOT NULL DEFAULT 'view',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE vault_entry (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
category_id UUID NOT NULL REFERENCES vault_category(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
username VARCHAR(255),
url VARCHAR(1000),
notes TEXT,
encrypted_value TEXT,
iv TEXT,
created_by UUID REFERENCES "user"(id),
updated_by UUID REFERENCES "user"(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_vault_category_permission_category ON vault_category_permission(category_id);
CREATE INDEX idx_vault_entry_category ON vault_entry(category_id);

View File

@@ -176,6 +176,13 @@
"when": 1774840000000,
"tag": "0024_add_traverse_access_level",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1774850000000,
"tag": "0025_vault",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,57 @@
import { pgTable, uuid, varchar, text, timestamp, boolean } from 'drizzle-orm/pg-core'
import { users } from './users.js'
import { roles } from './rbac.js'
import { storageFolderAccessEnum } from './storage.js'
export const vaultConfig = pgTable('vault_config', {
id: uuid('id').primaryKey().defaultRandom(),
masterKeySalt: text('master_key_salt').notNull(),
masterKeyHash: text('master_key_hash').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export const vaultCategories = pgTable('vault_category', {
id: uuid('id').primaryKey().defaultRandom(),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
createdBy: uuid('created_by').references(() => users.id),
isPublic: boolean('is_public').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export const vaultCategoryPermissions = pgTable('vault_category_permission', {
id: uuid('id').primaryKey().defaultRandom(),
categoryId: uuid('category_id')
.notNull()
.references(() => vaultCategories.id, { onDelete: 'cascade' }),
roleId: uuid('role_id').references(() => roles.id),
userId: uuid('user_id').references(() => users.id),
accessLevel: storageFolderAccessEnum('access_level').notNull().default('view'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export const vaultEntries = pgTable('vault_entry', {
id: uuid('id').primaryKey().defaultRandom(),
categoryId: uuid('category_id')
.notNull()
.references(() => vaultCategories.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 255 }).notNull(),
username: varchar('username', { length: 255 }),
url: varchar('url', { length: 1000 }),
notes: text('notes'),
encryptedValue: text('encrypted_value'),
iv: text('iv'),
createdBy: uuid('created_by').references(() => users.id),
updatedBy: uuid('updated_by').references(() => users.id),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export type VaultConfig = typeof vaultConfig.$inferSelect
export type VaultCategory = typeof vaultCategories.$inferSelect
export type VaultCategoryInsert = typeof vaultCategories.$inferInsert
export type VaultCategoryPermission = typeof vaultCategoryPermissions.$inferSelect
export type VaultEntry = typeof vaultEntries.$inferSelect
export type VaultEntryInsert = typeof vaultEntries.$inferInsert

View File

@@ -49,6 +49,11 @@ export const SYSTEM_PERMISSIONS = [
{ slug: 'files.upload', domain: 'files', action: 'upload', description: 'Upload files' },
{ slug: 'files.delete', domain: 'files', action: 'delete', description: 'Delete files' },
// Vault
{ slug: 'vault.view', domain: 'vault', action: 'view', description: 'View vault categories and entry names' },
{ slug: 'vault.edit', domain: 'vault', action: 'edit', description: 'Create/edit entries, reveal secret values' },
{ slug: 'vault.admin', domain: 'vault', action: 'admin', description: 'Manage categories, permissions, master password' },
// Email
{ slug: 'email.view', domain: 'email', action: 'view', description: 'View email logs' },
{ slug: 'email.send', domain: 'email', action: 'send', description: 'Send mass emails' },
@@ -99,6 +104,7 @@ export const DEFAULT_ROLES = [
'pos.view', 'pos.edit',
'rentals.view',
'files.view', 'files.upload',
'vault.view',
'reports.view',
],
},
@@ -111,6 +117,7 @@ export const DEFAULT_ROLES = [
'inventory.view',
'accounts.view',
'files.view', 'files.upload',
'vault.view',
],
},
{

View File

@@ -18,6 +18,7 @@ import { rbacRoutes } from './routes/v1/rbac.js'
import { repairRoutes } from './routes/v1/repairs.js'
import { storageRoutes } from './routes/v1/storage.js'
import { storeRoutes } from './routes/v1/store.js'
import { vaultRoutes } from './routes/v1/vault.js'
import { webdavRoutes } from './routes/webdav/index.js'
import { RbacService } from './services/rbac.service.js'
@@ -72,6 +73,7 @@ export async function buildApp() {
await app.register(repairRoutes, { prefix: '/v1' })
await app.register(storageRoutes, { prefix: '/v1' })
await app.register(storeRoutes, { prefix: '/v1' })
await app.register(vaultRoutes, { prefix: '/v1' })
// Register WebDAV custom HTTP methods before routes
app.addHttpMethod('PROPFIND', { hasBody: true })
app.addHttpMethod('PROPPATCH', { hasBody: true })

View File

@@ -0,0 +1,270 @@
import type { FastifyPluginAsync } from 'fastify'
import { PaginationSchema } from '@forte/shared/schemas'
import { VaultKeyService, VaultPermissionService, VaultCategoryService, VaultEntryService } from '../../services/vault.service.js'
import { ValidationError } from '../../lib/errors.js'
export const vaultRoutes: FastifyPluginAsync = async (app) => {
// --- Vault Status & Key Management ---
app.get('/vault/status', { preHandler: [app.authenticate, app.requirePermission('vault.view')] }, async (request, reply) => {
const initialized = await VaultKeyService.isInitialized(app.db)
const unlocked = VaultKeyService.isUnlocked()
return reply.send({ initialized, unlocked })
})
app.post('/vault/initialize', { preHandler: [app.authenticate, app.requirePermission('vault.admin')] }, async (request, reply) => {
const { masterPassword } = request.body as { masterPassword?: string }
if (!masterPassword || masterPassword.length < 8) throw new ValidationError('Master password must be at least 8 characters')
try {
await VaultKeyService.initialize(app.db, masterPassword)
return reply.status(201).send({ message: 'Vault initialized and unlocked' })
} catch (err: any) {
return reply.status(400).send({ error: { message: err.message, statusCode: 400 } })
}
})
app.post('/vault/unlock', { preHandler: [app.authenticate, app.requirePermission('vault.view')] }, async (request, reply) => {
const { masterPassword } = request.body as { masterPassword?: string }
if (!masterPassword) throw new ValidationError('Master password is required')
const success = await VaultKeyService.unlock(app.db, masterPassword)
if (!success) {
return reply.status(401).send({ error: { message: 'Invalid master password', statusCode: 401 } })
}
return reply.send({ message: 'Vault unlocked' })
})
app.post('/vault/lock', { preHandler: [app.authenticate, app.requirePermission('vault.admin')] }, async (request, reply) => {
VaultKeyService.lock()
return reply.send({ message: 'Vault locked' })
})
app.post('/vault/change-master-password', { preHandler: [app.authenticate, app.requirePermission('vault.admin')] }, async (request, reply) => {
const { currentPassword, newPassword } = request.body as { currentPassword?: string; newPassword?: string }
if (!currentPassword || !newPassword) throw new ValidationError('Both current and new passwords are required')
if (newPassword.length < 8) throw new ValidationError('New password must be at least 8 characters')
try {
await VaultKeyService.changeMasterPassword(app.db, currentPassword, newPassword)
return reply.send({ message: 'Master password changed' })
} catch (err: any) {
return reply.status(400).send({ error: { message: err.message, statusCode: 400 } })
}
})
// --- Middleware: require vault unlocked ---
function requireUnlocked(_request: any, reply: any, done: () => void) {
if (!VaultKeyService.isUnlocked()) {
reply.status(403).send({ error: { message: 'Vault is locked', statusCode: 403 } })
return
}
done()
}
// --- Categories ---
app.post('/vault/categories', { preHandler: [app.authenticate, app.requirePermission('vault.edit'), requireUnlocked] }, async (request, reply) => {
const { name, description, isPublic } = request.body as { name?: string; description?: string; isPublic?: boolean }
if (!name?.trim()) throw new ValidationError('Category name is required')
const category = await VaultCategoryService.create(app.db, { name: name.trim(), description, isPublic, createdBy: request.user.id })
return reply.status(201).send(category)
})
app.get('/vault/categories', { preHandler: [app.authenticate, app.requirePermission('vault.view'), requireUnlocked] }, async (request, reply) => {
const categories = await VaultCategoryService.listAccessible(app.db, request.user.id)
return reply.send({ data: categories })
})
app.get('/vault/categories/:id', { preHandler: [app.authenticate, app.requirePermission('vault.view'), requireUnlocked] }, async (request, reply) => {
const { id } = request.params as { id: string }
const category = await VaultCategoryService.getById(app.db, id)
if (!category) return reply.status(404).send({ error: { message: 'Category not found', statusCode: 404 } })
const accessLevel = await VaultPermissionService.getAccessLevel(app.db, id, request.user.id)
if (!accessLevel) return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } })
return reply.send({ ...category, accessLevel })
})
app.patch('/vault/categories/:id', { preHandler: [app.authenticate, app.requirePermission('vault.edit'), requireUnlocked] }, async (request, reply) => {
const { id } = request.params as { id: string }
const { name, description, isPublic } = request.body as { name?: string; description?: string; isPublic?: boolean }
const hasEdit = await VaultPermissionService.hasAccess(app.db, id, request.user.id, 'edit')
if (!hasEdit) return reply.status(403).send({ error: { message: 'No edit access', statusCode: 403 } })
const category = await VaultCategoryService.update(app.db, id, { name, description, isPublic })
if (!category) return reply.status(404).send({ error: { message: 'Category not found', statusCode: 404 } })
return reply.send(category)
})
app.delete('/vault/categories/:id', { preHandler: [app.authenticate, app.requirePermission('vault.admin'), requireUnlocked] }, async (request, reply) => {
const { id } = request.params as { id: string }
const hasAdmin = await VaultPermissionService.hasAccess(app.db, id, request.user.id, 'admin')
if (!hasAdmin) return reply.status(403).send({ error: { message: 'Admin access required', statusCode: 403 } })
const category = await VaultCategoryService.delete(app.db, id)
if (!category) return reply.status(404).send({ error: { message: 'Category not found', statusCode: 404 } })
return reply.send(category)
})
// --- Category Permissions ---
app.get('/vault/categories/:id/permissions', { preHandler: [app.authenticate, app.requirePermission('vault.view'), requireUnlocked] }, async (request, reply) => {
const { id } = request.params as { id: string }
const hasAdmin = await VaultPermissionService.hasAccess(app.db, id, request.user.id, 'admin')
if (!hasAdmin) return reply.status(403).send({ error: { message: 'Admin access required', statusCode: 403 } })
const permissions = await VaultPermissionService.listPermissions(app.db, id)
return reply.send({ data: permissions })
})
app.post('/vault/categories/:id/permissions', { preHandler: [app.authenticate, app.requirePermission('vault.edit'), requireUnlocked] }, async (request, reply) => {
const { id } = request.params as { id: string }
const { roleId, userId, accessLevel } = request.body as { roleId?: string; userId?: string; accessLevel?: string }
const hasAdmin = await VaultPermissionService.hasAccess(app.db, id, request.user.id, 'admin')
if (!hasAdmin) return reply.status(403).send({ error: { message: 'Admin access required', statusCode: 403 } })
if (!roleId && !userId) throw new ValidationError('Either roleId or userId is required')
if (!accessLevel || !['view', 'edit', 'admin'].includes(accessLevel)) throw new ValidationError('accessLevel must be view, edit, or admin')
const perm = await VaultPermissionService.setPermission(app.db, id, roleId, userId, accessLevel)
return reply.status(201).send(perm)
})
app.delete('/vault/category-permissions/:id', { preHandler: [app.authenticate, app.requirePermission('vault.admin'), requireUnlocked] }, async (request, reply) => {
const { id } = request.params as { id: string }
const perm = await VaultPermissionService.getPermissionById(app.db, id)
if (!perm) return reply.status(404).send({ error: { message: 'Permission not found', statusCode: 404 } })
const hasAdmin = await VaultPermissionService.hasAccess(app.db, perm.categoryId, request.user.id, 'admin')
if (!hasAdmin) return reply.status(403).send({ error: { message: 'Admin access required', statusCode: 403 } })
await VaultPermissionService.removePermission(app.db, id)
return reply.send(perm)
})
// --- Entries ---
app.post('/vault/categories/:categoryId/entries', { preHandler: [app.authenticate, app.requirePermission('vault.edit'), requireUnlocked] }, async (request, reply) => {
const { categoryId } = request.params as { categoryId: string }
const { name, username, url, notes, secret } = request.body as {
name?: string; username?: string; url?: string; notes?: string; secret?: string
}
if (!name?.trim()) throw new ValidationError('Entry name is required')
const hasEdit = await VaultPermissionService.hasAccess(app.db, categoryId, request.user.id, 'edit')
if (!hasEdit) return reply.status(403).send({ error: { message: 'No edit access', statusCode: 403 } })
const entry = await VaultEntryService.create(app.db, {
categoryId, name: name.trim(), username, url, notes, createdBy: request.user.id,
}, secret)
// Don't return encrypted value
return reply.status(201).send({
id: entry.id, categoryId: entry.categoryId, name: entry.name,
username: entry.username, url: entry.url, notes: entry.notes,
hasSecret: !!entry.encryptedValue,
createdBy: entry.createdBy, updatedBy: entry.updatedBy,
createdAt: entry.createdAt, updatedAt: entry.updatedAt,
})
})
app.get('/vault/categories/:categoryId/entries', { preHandler: [app.authenticate, app.requirePermission('vault.view'), requireUnlocked] }, async (request, reply) => {
const { categoryId } = request.params as { categoryId: string }
const hasView = await VaultPermissionService.hasAccess(app.db, categoryId, request.user.id, 'view')
if (!hasView) return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } })
const params = PaginationSchema.parse(request.query)
const result = await VaultEntryService.listByCategory(app.db, categoryId, params)
return reply.send(result)
})
app.get('/vault/entries/:id', { preHandler: [app.authenticate, app.requirePermission('vault.view'), requireUnlocked] }, async (request, reply) => {
const { id } = request.params as { id: string }
const entry = await VaultEntryService.getById(app.db, id)
if (!entry) return reply.status(404).send({ error: { message: 'Entry not found', statusCode: 404 } })
const hasView = await VaultPermissionService.hasAccess(app.db, entry.categoryId, request.user.id, 'view')
if (!hasView) return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } })
return reply.send({
id: entry.id, categoryId: entry.categoryId, name: entry.name,
username: entry.username, url: entry.url, notes: entry.notes,
hasSecret: !!entry.encryptedValue,
createdBy: entry.createdBy, updatedBy: entry.updatedBy,
createdAt: entry.createdAt, updatedAt: entry.updatedAt,
})
})
app.post('/vault/entries/:id/reveal', { preHandler: [app.authenticate, app.requirePermission('vault.edit'), requireUnlocked] }, async (request, reply) => {
const { id } = request.params as { id: string }
const entry = await VaultEntryService.getById(app.db, id)
if (!entry) return reply.status(404).send({ error: { message: 'Entry not found', statusCode: 404 } })
const hasEdit = await VaultPermissionService.hasAccess(app.db, entry.categoryId, request.user.id, 'edit')
if (!hasEdit) return reply.status(403).send({ error: { message: 'Edit access required to reveal secrets', statusCode: 403 } })
const value = await VaultEntryService.reveal(id, app.db)
return reply.send({ value })
})
app.patch('/vault/entries/:id', { preHandler: [app.authenticate, app.requirePermission('vault.edit'), requireUnlocked] }, async (request, reply) => {
const { id } = request.params as { id: string }
const { name, username, url, notes, secret } = request.body as {
name?: string; username?: string; url?: string; notes?: string; secret?: string
}
const entry = await VaultEntryService.getById(app.db, id)
if (!entry) return reply.status(404).send({ error: { message: 'Entry not found', statusCode: 404 } })
const hasEdit = await VaultPermissionService.hasAccess(app.db, entry.categoryId, request.user.id, 'edit')
if (!hasEdit) return reply.status(403).send({ error: { message: 'No edit access', statusCode: 403 } })
const updated = await VaultEntryService.update(app.db, id, {
name, username, url, notes, updatedBy: request.user.id,
}, secret)
return reply.send({
id: updated!.id, categoryId: updated!.categoryId, name: updated!.name,
username: updated!.username, url: updated!.url, notes: updated!.notes,
hasSecret: !!updated!.encryptedValue,
createdBy: updated!.createdBy, updatedBy: updated!.updatedBy,
createdAt: updated!.createdAt, updatedAt: updated!.updatedAt,
})
})
app.delete('/vault/entries/:id', { preHandler: [app.authenticate, app.requirePermission('vault.edit'), requireUnlocked] }, async (request, reply) => {
const { id } = request.params as { id: string }
const entry = await VaultEntryService.getById(app.db, id)
if (!entry) return reply.status(404).send({ error: { message: 'Entry not found', statusCode: 404 } })
const hasEdit = await VaultPermissionService.hasAccess(app.db, entry.categoryId, request.user.id, 'edit')
if (!hasEdit) return reply.status(403).send({ error: { message: 'No edit access', statusCode: 403 } })
await VaultEntryService.delete(app.db, id)
return reply.send(entry)
})
app.get('/vault/entries/search', { preHandler: [app.authenticate, app.requirePermission('vault.view'), requireUnlocked] }, async (request, reply) => {
const { q } = request.query as { q?: string }
if (!q) return reply.send({ data: [], pagination: { page: 1, limit: 25, total: 0, totalPages: 0 } })
const params = PaginationSchema.parse(request.query)
const result = await VaultEntryService.search(app.db, q, params)
// Filter by access
const filtered = []
for (const entry of result.data) {
const hasView = await VaultPermissionService.hasAccess(app.db, (entry as any).categoryId, request.user.id, 'view')
if (hasView) filtered.push(entry)
}
return reply.send({ data: filtered, pagination: { ...result.pagination, total: filtered.length } })
})
}

View File

@@ -0,0 +1,372 @@
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 '@forte/shared/schemas'
import { randomBytes, createCipheriv, createDecipheriv, pbkdf2Sync } from 'crypto'
import bcrypt from 'bcrypt'
// --- 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<string, number> = { traverse: 0, view: 1, edit: 2, admin: 3 }
// --- Key Service ---
export const VaultKeyService = {
isUnlocked(): boolean {
return derivedKey !== null
},
async isInitialized(db: PostgresJsDatabase<any>): Promise<boolean> {
const [config] = await db.select({ id: vaultConfig.id }).from(vaultConfig).limit(1)
return !!config
},
async initialize(db: PostgresJsDatabase<any>, masterPassword: string): Promise<void> {
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<any>, masterPassword: string): Promise<boolean> {
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<any>, currentPassword: string, newPassword: string): Promise<void> {
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<any>, categoryId: string, userId: string, minLevel: 'traverse' | 'view' | 'edit' | 'admin' = 'view'): Promise<boolean> {
const level = await this.getAccessLevel(db, categoryId, userId)
if (!level) return false
return ACCESS_RANK[level] >= ACCESS_RANK[minLevel]
},
async getAccessLevel(db: PostgresJsDatabase<any>, 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<any>, categoryId: string) {
return db.select().from(vaultCategoryPermissions).where(eq(vaultCategoryPermissions.categoryId, categoryId))
},
async getPermissionById(db: PostgresJsDatabase<any>, permissionId: string) {
const [perm] = await db.select().from(vaultCategoryPermissions).where(eq(vaultCategoryPermissions.id, permissionId)).limit(1)
return perm ?? null
},
async setPermission(db: PostgresJsDatabase<any>, 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<any>, 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<any>, 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<any>, id: string) {
const [category] = await db.select().from(vaultCategories).where(eq(vaultCategories.id, id)).limit(1)
return category ?? null
},
async listAccessible(db: PostgresJsDatabase<any>, 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<any>, 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<any>, 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<any>, 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<any>, id: string) {
const [entry] = await db.select().from(vaultEntries).where(eq(vaultEntries.id, id)).limit(1)
return entry ?? null
},
async listByCategory(db: PostgresJsDatabase<any>, 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<string, Column> = {
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<any>): Promise<string | null> {
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<any>, id: string, input: {
name?: string; username?: string; url?: string; notes?: string; updatedBy?: string
}, newSecret?: string) {
const updates: Record<string, any> = { ...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<any>, id: string) {
const [entry] = await db.delete(vaultEntries).where(eq(vaultEntries.id, id)).returning()
return entry ?? null
},
async search(db: PostgresJsDatabase<any>, 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)
},
}