From 724658795503560b7abdc436023f66f058e50a08 Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Mon, 30 Mar 2026 06:11:33 -0500 Subject: [PATCH] Add vault secret manager backend with AES-256-GCM encryption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/backend/api-tests/suites/vault.ts | 232 +++++++++++ .../backend/src/db/migrations/0025_vault.sql | 46 +++ .../src/db/migrations/meta/_journal.json | 7 + packages/backend/src/db/schema/vault.ts | 57 +++ packages/backend/src/db/seeds/rbac.ts | 7 + packages/backend/src/main.ts | 2 + packages/backend/src/routes/v1/vault.ts | 270 +++++++++++++ .../backend/src/services/vault.service.ts | 372 ++++++++++++++++++ 8 files changed, 993 insertions(+) create mode 100644 packages/backend/api-tests/suites/vault.ts create mode 100644 packages/backend/src/db/migrations/0025_vault.sql create mode 100644 packages/backend/src/db/schema/vault.ts create mode 100644 packages/backend/src/routes/v1/vault.ts create mode 100644 packages/backend/src/services/vault.service.ts diff --git a/packages/backend/api-tests/suites/vault.ts b/packages/backend/api-tests/suites/vault.ts new file mode 100644 index 0000000..991fc4e --- /dev/null +++ b/packages/backend/api-tests/suites/vault.ts @@ -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) + }) +}) diff --git a/packages/backend/src/db/migrations/0025_vault.sql b/packages/backend/src/db/migrations/0025_vault.sql new file mode 100644 index 0000000..7de99da --- /dev/null +++ b/packages/backend/src/db/migrations/0025_vault.sql @@ -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); diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index 2388a3c..26076de 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/vault.ts b/packages/backend/src/db/schema/vault.ts new file mode 100644 index 0000000..74f46d8 --- /dev/null +++ b/packages/backend/src/db/schema/vault.ts @@ -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 diff --git a/packages/backend/src/db/seeds/rbac.ts b/packages/backend/src/db/seeds/rbac.ts index 70f6adf..22f9a79 100644 --- a/packages/backend/src/db/seeds/rbac.ts +++ b/packages/backend/src/db/seeds/rbac.ts @@ -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', ], }, { diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 52f1577..9be003c 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -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 }) diff --git a/packages/backend/src/routes/v1/vault.ts b/packages/backend/src/routes/v1/vault.ts new file mode 100644 index 0000000..c17b87d --- /dev/null +++ b/packages/backend/src/routes/v1/vault.ts @@ -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 } }) + }) +} diff --git a/packages/backend/src/services/vault.service.ts b/packages/backend/src/services/vault.service.ts new file mode 100644 index 0000000..796b57e --- /dev/null +++ b/packages/backend/src/services/vault.service.ts @@ -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 = { 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) + }, +}