diff --git a/.env.development b/.env.development index 989e7f5e9..9626b3a50 100644 --- a/.env.development +++ b/.env.development @@ -64,6 +64,15 @@ S3_SECRET_KEY=minioadmin MANA_AUTH_PORT=3001 MANA_AUTH_DATABASE_URL=postgresql://mana:devpassword@localhost:5432/mana_platform + +# Encryption Vault Key Encryption Key (KEK) +# Wraps each user's master key in auth.encryption_vaults. In development +# this can stay empty (a deterministic dev fallback is used + a loud +# warning is logged at boot). In production it MUST be set to a base64- +# encoded 32-byte random value: `openssl rand -base64 32` +# Future: migrate to KMS / Vault — keep the env var for now. +MANA_AUTH_KEK= + JWT_ACCESS_TOKEN_EXPIRY=15m JWT_REFRESH_TOKEN_EXPIRY=7d JWT_ISSUER=mana diff --git a/services/mana-auth/package.json b/services/mana-auth/package.json index 44e786a3c..d3621ce96 100644 --- a/services/mana-auth/package.json +++ b/services/mana-auth/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "bun run --watch src/index.ts", "start": "bun run src/index.ts", + "test": "bun test", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", "db:studio": "drizzle-kit studio" diff --git a/services/mana-auth/sql/002_encryption_vaults.sql b/services/mana-auth/sql/002_encryption_vaults.sql new file mode 100644 index 000000000..37c1f14c0 --- /dev/null +++ b/services/mana-auth/sql/002_encryption_vaults.sql @@ -0,0 +1,78 @@ +-- Migration: encryption_vaults + encryption_vault_audit +-- +-- Adds the per-user encryption vault that holds each user's master key +-- (MK) wrapped with a service-wide Key Encryption Key (KEK). The KEK +-- itself never lives in the database — it is loaded from the +-- MANA_AUTH_KEK env var (later: a KMS / Vault). +-- +-- Run this BEFORE deploying the encryption Phase 2 mana-auth release. +-- After this migration, run `pnpm db:push` from services/mana-auth/ +-- to materialize the Drizzle-defined columns (or just deploy the new +-- service — Drizzle creates the tables on boot). +-- +-- The Drizzle schema definition lives in +-- src/db/schema/encryption-vaults.ts. This SQL file only adds the +-- bits Drizzle cannot model: row-level security policies + the FORCE +-- option that makes the policies apply even to the table owner. + +-- ─── Tables ─────────────────────────────────────────────────── +-- Table CREATE statements are intentionally idempotent so this file +-- can be re-run on a partially-migrated database without crashing. + +CREATE TABLE IF NOT EXISTS auth.encryption_vaults ( + user_id TEXT PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + wrapped_mk TEXT NOT NULL, + wrap_iv TEXT NOT NULL, + format_version SMALLINT NOT NULL DEFAULT 1, + kek_id TEXT NOT NULL DEFAULT 'env-v1', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + rotated_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS encryption_vaults_user_id_idx + ON auth.encryption_vaults (user_id); + +CREATE TABLE IF NOT EXISTS auth.encryption_vault_audit ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + action TEXT NOT NULL, + ip_address TEXT, + user_agent TEXT, + context TEXT, + status INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS encryption_vault_audit_user_id_idx + ON auth.encryption_vault_audit (user_id); + +CREATE INDEX IF NOT EXISTS encryption_vault_audit_created_at_idx + ON auth.encryption_vault_audit (created_at); + +-- ─── Row Level Security ─────────────────────────────────────── +-- +-- Defense-in-depth: even if a future query forgets the WHERE +-- user_id = $1 clause, the database itself refuses to leak rows +-- belonging to other users. The vault service wraps every read +-- and write in a transaction that calls +-- set_config('app.current_user_id', userId, true) +-- before touching the table — RLS rejects anything else. +-- +-- FORCE makes the policy apply to the table owner too, so the +-- mana-auth service role cannot bypass it via grants alone. + +ALTER TABLE auth.encryption_vaults ENABLE ROW LEVEL SECURITY; +ALTER TABLE auth.encryption_vaults FORCE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS encryption_vaults_user_isolation ON auth.encryption_vaults; +CREATE POLICY encryption_vaults_user_isolation ON auth.encryption_vaults + USING (user_id = current_setting('app.current_user_id', true)) + WITH CHECK (user_id = current_setting('app.current_user_id', true)); + +ALTER TABLE auth.encryption_vault_audit ENABLE ROW LEVEL SECURITY; +ALTER TABLE auth.encryption_vault_audit FORCE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS encryption_vault_audit_user_isolation ON auth.encryption_vault_audit; +CREATE POLICY encryption_vault_audit_user_isolation ON auth.encryption_vault_audit + USING (user_id = current_setting('app.current_user_id', true)) + WITH CHECK (user_id = current_setting('app.current_user_id', true)); diff --git a/services/mana-auth/src/config.ts b/services/mana-auth/src/config.ts index 1619be43b..75b3c9b03 100644 --- a/services/mana-auth/src/config.ts +++ b/services/mana-auth/src/config.ts @@ -11,28 +11,51 @@ export interface Config { manaCreditsUrl: string; manaSubscriptionsUrl: string; synapseOidcClientSecret: string; + /** Base64-encoded 32-byte AES-256 key encryption key (KEK). Wraps each + * user's master key in auth.encryption_vaults. Required in production + * — in development a deterministic dev KEK is auto-generated so the + * service still boots, with a loud warning. */ + encryptionKek: string; } export function loadConfig(): Config { const env = (key: string, fallback?: string) => process.env[key] || fallback || ''; + const nodeEnv = env('NODE_ENV', 'development'); + + // Encryption KEK: in production a missing/short value is fatal — the + // vault service refuses to mint or unwrap any master keys without a + // real KEK. In development we auto-fill with a deterministic dev key + // so contributors can run the service without setting up a secret. + let encryptionKek = env('MANA_AUTH_KEK'); + if (!encryptionKek) { + if (nodeEnv === 'production') { + throw new Error( + 'mana-auth: MANA_AUTH_KEK env var is required in production. ' + + 'Set it to a base64-encoded 32-byte random value: ' + + '`openssl rand -base64 32`' + ); + } + // 32 zero bytes — deterministic, obviously not for production. The + // vault service logs a loud warning at startup when it sees this. + encryptionKek = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='; + } + return { port: parseInt(env('PORT', '3001'), 10), - databaseUrl: env( - 'DATABASE_URL', - 'postgresql://mana:devpassword@localhost:5432/mana_platform' - ), + databaseUrl: env('DATABASE_URL', 'postgresql://mana:devpassword@localhost:5432/mana_platform'), syncDatabaseUrl: env( 'SYNC_DATABASE_URL', 'postgresql://mana:devpassword@localhost:5432/mana_sync' ), baseUrl: env('BASE_URL', 'http://localhost:3001'), cookieDomain: env('COOKIE_DOMAIN'), - nodeEnv: env('NODE_ENV', 'development'), + nodeEnv, serviceKey: env('MANA_SERVICE_KEY', 'dev-service-key'), cors: { origins: env('CORS_ORIGINS', 'http://localhost:5173').split(',') }, manaNotifyUrl: env('MANA_NOTIFY_URL', 'http://localhost:3013'), manaCreditsUrl: env('MANA_CREDITS_URL', 'http://localhost:3061'), manaSubscriptionsUrl: env('MANA_SUBSCRIPTIONS_URL', 'http://localhost:3063'), synapseOidcClientSecret: env('SYNAPSE_OIDC_CLIENT_SECRET'), + encryptionKek, }; } diff --git a/services/mana-auth/src/db/schema/encryption-vaults.ts b/services/mana-auth/src/db/schema/encryption-vaults.ts new file mode 100644 index 000000000..b5bcfd722 --- /dev/null +++ b/services/mana-auth/src/db/schema/encryption-vaults.ts @@ -0,0 +1,100 @@ +import { text, timestamp, smallint, integer, index } from 'drizzle-orm/pg-core'; +import { authSchema, users } from './auth'; + +/** + * Per-user encryption vault. + * + * Holds the user's master key (MK) — wrapped with the service-wide Key + * Encryption Key (KEK). The MK itself is never stored in plaintext. + * Browsers fetch the unwrapped MK at login via `GET /api/v1/me/encryption-key` + * and keep it in sessionStorage for the duration of the session. + * + * Wire format of the wrapped key: + * AES-GCM-256 over the raw 32-byte MK, with the KEK as key. + * wrapped_mk = AES-GCM-encrypt(MK, KEK, wrap_iv) → ciphertext + 16-byte auth tag. + * The auth tag is appended to wrapped_mk by the Web Crypto / Bun crypto API. + * + * Why a separate table (and not a column on users)? + * - Lifecycle is independent: a user can rotate their vault without + * touching the user record, and vice versa. + * - Permissions: only the dedicated vault service touches this table, + * so it's easy to grant minimal access via row-level security and + * restrict the audit surface. + * - Future-proofing: when we add per-device sub-keys or recovery wraps, + * they sit naturally next to the master entry. + * + * RLS is added via raw SQL in the migration file alongside the table. + * The migration enables ROW LEVEL SECURITY + FORCE so that even the + * mana-auth service role cannot read another user's vault entry without + * going through `set_config('app.current_user_id', ...)` first. + */ +export const encryptionVaults = authSchema.table( + 'encryption_vaults', + { + userId: text('user_id') + .primaryKey() + .references(() => users.id, { onDelete: 'cascade' }), + + /** AES-GCM ciphertext of the raw 32-byte master key. Includes the + * 16-byte authentication tag at the tail (Web Crypto convention). */ + wrappedMk: text('wrapped_mk').notNull(), + + /** 12-byte IV used for the wrap operation. Stored base64. */ + wrapIv: text('wrap_iv').notNull(), + + /** Wire format version. Lets us migrate to a different KDF or AEAD + * later without rewriting every existing row at once. */ + formatVersion: smallint('format_version').notNull().default(1), + + /** KEK identifier — currently always 'env-v1' (the env-loaded KEK). + * Will become a KMS key ARN / Vault path / etc. when we move + * off the env-var KEK. Stored so a future rotation knows which + * KEK to unwrap with. */ + kekId: text('kek_id').notNull().default('env-v1'), + + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + rotatedAt: timestamp('rotated_at', { withTimezone: true }), + }, + (table) => [index('encryption_vaults_user_id_idx').on(table.userId)] +); + +export type EncryptionVault = typeof encryptionVaults.$inferSelect; +export type NewEncryptionVault = typeof encryptionVaults.$inferInsert; + +/** + * Append-only audit trail of vault accesses (init, fetch, rotate). Used + * for security investigations and compliance reporting. Not exposed to + * users — only the admin endpoints can read this. + * + * Why a separate table instead of dumping into a generic audit log? + * - Encryption vault access is the highest-sensitivity operation in + * the entire system; a dedicated table makes the threat-monitoring + * query trivial ("show me all fetches in the last 24h grouped by + * IP / user-agent"). + * - Retention can be tuned independently (longer than ordinary auth + * logs to support late-discovered breaches). + */ +export const encryptionVaultAudit = authSchema.table( + 'encryption_vault_audit', + { + id: text('id').primaryKey(), // nanoid + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + action: text('action').notNull(), // 'init' | 'fetch' | 'rotate' | 'failed_fetch' + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + /** Free-form context (e.g. failure reason, format version touched). */ + context: text('context'), + /** HTTP status returned to the client — useful for spotting probing. */ + status: integer('status').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('encryption_vault_audit_user_id_idx').on(table.userId), + index('encryption_vault_audit_created_at_idx').on(table.createdAt), + ] +); + +export type EncryptionVaultAudit = typeof encryptionVaultAudit.$inferSelect; +export type NewEncryptionVaultAudit = typeof encryptionVaultAudit.$inferInsert; diff --git a/services/mana-auth/src/db/schema/index.ts b/services/mana-auth/src/db/schema/index.ts index fae636ca4..b2d3ad7b7 100644 --- a/services/mana-auth/src/db/schema/index.ts +++ b/services/mana-auth/src/db/schema/index.ts @@ -2,3 +2,4 @@ export * from './auth'; export * from './organizations'; export * from './api-keys'; export * from './login-attempts'; +export * from './encryption-vaults'; diff --git a/services/mana-auth/src/index.ts b/services/mana-auth/src/index.ts index 737d7dfa3..9d5c6a47d 100644 --- a/services/mana-auth/src/index.ts +++ b/services/mana-auth/src/index.ts @@ -17,10 +17,13 @@ import { SecurityEventsService, AccountLockoutService } from './services/securit import { SignupLimitService } from './services/signup-limit'; import { ApiKeysService } from './services/api-keys'; import { UserDataService } from './services/user-data'; +import { EncryptionVaultService } from './services/encryption-vault'; +import { loadKek } from './services/encryption-vault/kek'; import { createAuthRoutes } from './routes/auth'; import { createGuildRoutes } from './routes/guilds'; import { createApiKeyRoutes, createApiKeyValidationRoute } from './routes/api-keys'; import { createMeRoutes } from './routes/me'; +import { createEncryptionVaultRoutes } from './routes/encryption-vault'; import { createSettingsRoutes } from './routes/settings'; import { createAdminRoutes } from './routes/admin'; @@ -30,12 +33,18 @@ const config = loadConfig(); const db = getDb(config.databaseUrl); const auth = createBetterAuth(config.databaseUrl); +// Load the Key Encryption Key before any vault operation can run. +// Top-level await is supported by Bun. Throws if MANA_AUTH_KEK is +// missing in production or malformed in any environment. +await loadKek(config.encryptionKek); + // Initialize services const security = new SecurityEventsService(db); const lockout = new AccountLockoutService(db); const signupLimit = new SignupLimitService(db); const apiKeysService = new ApiKeysService(db); const userDataService = new UserDataService(db, config); +const encryptionVaultService = new EncryptionVaultService(db); // ─── App ──────────────────────────────────────────────────── @@ -83,6 +92,11 @@ app.route('/api/v1/api-keys', createApiKeyValidationRoute(apiKeysService)); app.use('/api/v1/me/*', jwtAuth(config.baseUrl)); app.route('/api/v1/me', createMeRoutes(userDataService)); +// ─── Encryption vault (per-user master key custody) ──────── +// Mounted under /me so it inherits the JWT middleware above and shows +// up in the same self-service surface as the GDPR endpoints. +app.route('/api/v1/me/encryption-vault', createEncryptionVaultRoutes(encryptionVaultService)); + // ─── Settings ────────────────────────────────────────────── app.use('/api/v1/settings/*', jwtAuth(config.baseUrl)); diff --git a/services/mana-auth/src/routes/encryption-vault.ts b/services/mana-auth/src/routes/encryption-vault.ts new file mode 100644 index 000000000..1ed1de4c8 --- /dev/null +++ b/services/mana-auth/src/routes/encryption-vault.ts @@ -0,0 +1,119 @@ +/** + * Encryption vault routes — `/api/v1/me/encryption-vault/*` + * + * The browser fetches its master key from these endpoints at login and + * stashes the result in sessionStorage. All routes require a valid JWT + * via the standard jwt-auth middleware — there is no admin or service- + * to-service variant. The vault is a strictly per-user resource. + * + * Routes: + * POST /init → Mints a fresh MK if none exists, then returns it. + * Idempotent — calling twice is safe and returns + * the existing key on the second call. + * GET /key → Returns the existing MK. 404 if not initialised + * (client should call /init). + * POST /rotate → Mints a new MK, replaces the existing wrap. Caller + * MUST handle re-encryption of any data sealed with + * the old key. + * + * The master key crosses the wire as base64 — never as raw bytes — so + * a JSON-aware client (browser, curl, jq) can deserialise it without + * worrying about binary content. + * + * Audit logging is the service's job; the route just passes ip + UA in + * via AuditContext. + */ + +import { Hono, type Context } from 'hono'; +import type { AuthUser } from '../middleware/jwt-auth'; +import { + EncryptionVaultService, + VaultNotFoundError, + type AuditContext, +} from '../services/encryption-vault'; + +type AppContext = Context<{ Variables: { user: AuthUser } }>; + +export function createEncryptionVaultRoutes(vaultService: EncryptionVaultService) { + const app = new Hono<{ Variables: { user: AuthUser } }>(); + + // ─── POST /init ────────────────────────────────────────── + // Idempotent. First call creates a vault row; subsequent calls + // return the existing master key. The client uses this on first + // login per device — `init` is also a safe fallback if `/key` + // returns 404 because the user has somehow never been initialised. + app.post('/init', async (c) => { + const user = c.get('user'); + const ctx = readAuditContext(c); + + const result = await vaultService.init(user.userId, ctx); + + return c.json({ + masterKey: bytesToBase64(result.masterKey), + formatVersion: result.formatVersion, + kekId: result.kekId, + }); + }); + + // ─── GET /key ──────────────────────────────────────────── + // The hot path: every Phase 3 client calls this immediately after + // login. Returns the unwrapped MK as base64 over HTTPS. The vault + // service writes a `fetch` audit row on success, `failed_fetch` on + // any error path. + app.get('/key', async (c) => { + const user = c.get('user'); + const ctx = readAuditContext(c); + + try { + const result = await vaultService.getMasterKey(user.userId, ctx); + return c.json({ + masterKey: bytesToBase64(result.masterKey), + formatVersion: result.formatVersion, + kekId: result.kekId, + }); + } catch (err) { + if (err instanceof VaultNotFoundError) { + return c.json({ error: 'vault not initialised', code: 'VAULT_NOT_INITIALISED' }, 404); + } + throw err; // 500 via global error handler + audit row already written + } + }); + + // ─── POST /rotate ──────────────────────────────────────── + // Destructive. Mints a fresh MK and overwrites the wrap. The old MK + // is gone forever. Routes do NOT enforce a 2FA challenge here — + // that's a UX decision the front-end has to enforce before calling. + // (Future: add a `requires2fa: true` flag and short-circuit here if + // the JWT lacks a recent step-up claim.) + app.post('/rotate', async (c) => { + const user = c.get('user'); + const ctx = readAuditContext(c); + + const result = await vaultService.rotate(user.userId, ctx); + return c.json({ + masterKey: bytesToBase64(result.masterKey), + formatVersion: result.formatVersion, + kekId: result.kekId, + }); + }); + + return app; +} + +// ─── Helpers ───────────────────────────────────────────────── + +function readAuditContext(c: AppContext): AuditContext { + return { + ipAddress: + c.req.header('x-forwarded-for')?.split(',')[0]?.trim() || + c.req.header('x-real-ip') || + undefined, + userAgent: c.req.header('user-agent') || undefined, + }; +} + +function bytesToBase64(bytes: Uint8Array): string { + let bin = ''; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin); +} diff --git a/services/mana-auth/src/services/encryption-vault/index.ts b/services/mana-auth/src/services/encryption-vault/index.ts new file mode 100644 index 000000000..4b55ab63b --- /dev/null +++ b/services/mana-auth/src/services/encryption-vault/index.ts @@ -0,0 +1,249 @@ +/** + * EncryptionVaultService — server-side master key custody. + * + * Responsibilities: + * - init(userId): mint a fresh per-user master key, wrap it with the + * KEK, and store it. Idempotent: returns the existing vault if one + * already exists for this user. + * - getMasterKey(userId): unwrap the stored MK and return the raw 32 + * bytes ready for HTTPS transit to the browser. + * - rotate(userId): mint a fresh MK, replace the existing wrap. The + * old MK is GONE — the caller must ensure all encrypted data is + * re-encrypted (or accepted as lost) before invoking rotate. + * + * All reads and writes go through `withUserScope(userId, fn)` so the + * row-level-security policy on `auth.encryption_vaults` and + * `auth.encryption_vault_audit` is satisfied. The transaction sets + * `app.current_user_id` via `set_config(..., true)` (LOCAL scope) so + * even if a future bug forgets the WHERE clause, the database refuses + * to expose another user's vault entry. + * + * The audit table records every action — successful and failed — with + * IP, user-agent, and HTTP status. Routes pass these in via the + * AuditContext shape. + */ + +import { eq, sql } from 'drizzle-orm'; +import { nanoid } from 'nanoid'; +import type { Database } from '../../db/connection'; +import { + encryptionVaults, + encryptionVaultAudit, + type EncryptionVault, +} from '../../db/schema/encryption-vaults'; +import { wrapMasterKey, unwrapMasterKey, generateMasterKey, activeKekId } from './kek'; + +/** Per-request metadata used for audit log entries. */ +export interface AuditContext { + ipAddress?: string; + userAgent?: string; +} + +export interface VaultFetchResult { + /** Raw 32 bytes of the unwrapped master key. Caller must base64-encode + * before placing in the JSON response body. */ + masterKey: Uint8Array; + /** Format version of the wrap currently in storage — bumps if we ever + * migrate the wire format. The client doesn't usually care, but the + * rotate flow uses it to know whether a re-wrap is needed. */ + formatVersion: number; + /** Which KEK produced the wrapped value. */ + kekId: string; +} + +export class EncryptionVaultService { + constructor(private db: Database) {} + + // ─── Public API ────────────────────────────────────────── + + /** + * Idempotent vault initialisation. Returns the existing vault row if + * one already exists for this user, otherwise mints a fresh master + * key, wraps it with the KEK, and inserts. + * + * Returns the unwrapped master key bytes either way so the client + * can stash them immediately after the call. + */ + async init(userId: string, ctx: AuditContext = {}): Promise { + return this.withUserScope(userId, async (tx) => { + const existing = await tx + .select() + .from(encryptionVaults) + .where(eq(encryptionVaults.userId, userId)) + .limit(1); + + if (existing.length > 0) { + // Already initialised — fall through to a regular fetch. + const masterKey = await unwrapMasterKey(existing[0].wrappedMk, existing[0].wrapIv); + await this.writeAudit(tx, userId, 'init', ctx, 200, 'already-exists'); + return { + masterKey, + formatVersion: existing[0].formatVersion, + kekId: existing[0].kekId, + }; + } + + const mkBytes = generateMasterKey(); + const { wrappedMk, wrapIv } = await wrapMasterKey(mkBytes); + + await tx.insert(encryptionVaults).values({ + userId, + wrappedMk, + wrapIv, + formatVersion: 1, + kekId: activeKekId(), + }); + + await this.writeAudit(tx, userId, 'init', ctx, 201, 'created'); + + return { masterKey: mkBytes, formatVersion: 1, kekId: activeKekId() }; + }); + } + + /** + * Fetches the current master key for a user. Throws if no vault has + * been initialised yet — the route handler converts that to a 404 so + * the client can call init() to bootstrap. + */ + async getMasterKey(userId: string, ctx: AuditContext = {}): Promise { + return this.withUserScope(userId, async (tx) => { + const rows = await tx + .select() + .from(encryptionVaults) + .where(eq(encryptionVaults.userId, userId)) + .limit(1); + + if (rows.length === 0) { + await this.writeAudit(tx, userId, 'failed_fetch', ctx, 404, 'not-initialised'); + throw new VaultNotFoundError(userId); + } + + const row = rows[0]; + let masterKey: Uint8Array; + try { + masterKey = await unwrapMasterKey(row.wrappedMk, row.wrapIv); + } catch (err) { + // Auth-tag mismatch, wrong KEK, malformed row — all the same + // to the caller (500), but we want a clear audit trail. + await this.writeAudit( + tx, + userId, + 'failed_fetch', + ctx, + 500, + `unwrap-failed: ${(err as Error).message}` + ); + throw err; + } + + await this.writeAudit(tx, userId, 'fetch', ctx, 200, null); + return { masterKey, formatVersion: row.formatVersion, kekId: row.kekId }; + }); + } + + /** + * Rotates a user's master key. The old MK is permanently lost — the + * caller is responsible for re-encrypting any data that was sealed + * with it BEFORE calling this method, or accepting the loss. + * + * Use cases: + * - Suspected device compromise → rotate + force logout all + * sessions + tell user "your old data needs re-syncing" + * - Periodic best-practice rotation (rare in this design — the + * KEK can rotate without touching the MK) + */ + async rotate(userId: string, ctx: AuditContext = {}): Promise { + return this.withUserScope(userId, async (tx) => { + const mkBytes = generateMasterKey(); + const { wrappedMk, wrapIv } = await wrapMasterKey(mkBytes); + + const updated = await tx + .update(encryptionVaults) + .set({ + wrappedMk, + wrapIv, + kekId: activeKekId(), + rotatedAt: new Date(), + }) + .where(eq(encryptionVaults.userId, userId)) + .returning(); + + if (updated.length === 0) { + // No existing vault — treat rotate as init. + await tx.insert(encryptionVaults).values({ + userId, + wrappedMk, + wrapIv, + formatVersion: 1, + kekId: activeKekId(), + }); + await this.writeAudit(tx, userId, 'rotate', ctx, 201, 'init-on-rotate'); + } else { + await this.writeAudit(tx, userId, 'rotate', ctx, 200, null); + } + + return { masterKey: mkBytes, formatVersion: 1, kekId: activeKekId() }; + }); + } + + // ─── Internals ─────────────────────────────────────────── + + /** + * Wraps `fn` in a transaction with `app.current_user_id` set to the + * given userId via `set_config(..., true)`. RLS policies on + * encryption_vaults and encryption_vault_audit then admit only rows + * matching that userId — defense in depth on top of the explicit + * WHERE clauses. + * + * `set_config(name, value, true)` is the parameterised equivalent of + * `SET LOCAL` (which can't take bind parameters). The `true` flag + * scopes the setting to the current transaction. + */ + private async withUserScope( + userId: string, + fn: (tx: Parameters[0]>[0]) => Promise + ): Promise { + if (!userId) { + throw new Error('mana-auth/vault: userId is required for vault operations'); + } + return this.db.transaction(async (tx) => { + await tx.execute(sql`SELECT set_config('app.current_user_id', ${userId}, true)`); + return fn(tx); + }); + } + + private async writeAudit( + tx: Parameters[0]>[0], + userId: string, + action: 'init' | 'fetch' | 'rotate' | 'failed_fetch', + ctx: AuditContext, + status: number, + context: string | null + ): Promise { + await tx.insert(encryptionVaultAudit).values({ + id: nanoid(), + userId, + action, + ipAddress: ctx.ipAddress ?? null, + userAgent: ctx.userAgent ?? null, + context, + status, + }); + } +} + +/** + * Thrown when a fetch is attempted against a user who hasn't called + * init() yet. Routes catch this specifically to convert it to a 404 + * (so the client can react with init() instead of treating it as a + * server error). + */ +export class VaultNotFoundError extends Error { + constructor(public userId: string) { + super(`encryption vault not initialised for user ${userId}`); + this.name = 'VaultNotFoundError'; + } +} + +/** Re-export the type for route handlers. */ +export type { EncryptionVault }; diff --git a/services/mana-auth/src/services/encryption-vault/kek.test.ts b/services/mana-auth/src/services/encryption-vault/kek.test.ts new file mode 100644 index 000000000..3632f610b --- /dev/null +++ b/services/mana-auth/src/services/encryption-vault/kek.test.ts @@ -0,0 +1,134 @@ +/** + * KEK (Key Encryption Key) helper tests. + * + * Pure crypto — no Postgres or Drizzle dependency. Run with `bun test`. + * + * The EncryptionVaultService itself is tested via integration tests + * against a real Postgres instance because the row-level-security + * behaviour cannot be faithfully reproduced with pg-mem or sqlite. + * Those integration tests live alongside the existing mana-sync test + * pattern (separate test database, set up in CI before the run). + */ + +import { describe, it, expect, beforeEach } from 'bun:test'; +import { + loadKek, + wrapMasterKey, + unwrapMasterKey, + generateMasterKey, + activeKekId, + _resetForTesting, +} from './kek'; + +// Deterministic 32-byte test KEK (NOT the dev fallback — that's all +// zeros, which would trigger the warning every test run). +const TEST_KEK_BASE64 = 'AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA='; + +beforeEach(async () => { + _resetForTesting(); + await loadKek(TEST_KEK_BASE64); +}); + +describe('loadKek', () => { + it('imports a valid 32-byte base64 KEK', () => { + expect(activeKekId()).toBe('env-v1'); + }); + + it('rejects a base64 string that decodes to the wrong length', async () => { + _resetForTesting(); + // 16 bytes — half the size of an AES-256 KEK + await expect(loadKek('AAAAAAAAAAAAAAAAAAAAAA==')).rejects.toThrow(/expected 32 bytes/); + }); + + it('is idempotent — second call is a no-op', async () => { + // Already loaded in beforeEach. A second call should not throw. + await loadKek(TEST_KEK_BASE64); + expect(activeKekId()).toBe('env-v1'); + }); + + it('refuses to expose kekId before loadKek is called', () => { + _resetForTesting(); + expect(() => activeKekId()).toThrow(/loadKek\(\) not called/); + }); +}); + +describe('generateMasterKey', () => { + it('returns 32 bytes of cryptographic randomness', () => { + const a = generateMasterKey(); + const b = generateMasterKey(); + expect(a).toBeInstanceOf(Uint8Array); + expect(a.length).toBe(32); + expect(b.length).toBe(32); + // Two consecutive calls should virtually never collide + expect(Buffer.from(a).toString('hex')).not.toBe(Buffer.from(b).toString('hex')); + }); +}); + +describe('wrapMasterKey / unwrapMasterKey roundtrip', () => { + it('roundtrips a freshly generated master key', async () => { + const mk = generateMasterKey(); + const { wrappedMk, wrapIv } = await wrapMasterKey(mk); + + expect(typeof wrappedMk).toBe('string'); + expect(typeof wrapIv).toBe('string'); + expect(wrappedMk.length).toBeGreaterThan(0); + expect(wrapIv.length).toBeGreaterThan(0); + + const recovered = await unwrapMasterKey(wrappedMk, wrapIv); + expect(Buffer.from(recovered).toString('hex')).toBe(Buffer.from(mk).toString('hex')); + }); + + it('produces a different ciphertext for the same MK on each call', async () => { + const mk = generateMasterKey(); + const a = await wrapMasterKey(mk); + const b = await wrapMasterKey(mk); + const c = await wrapMasterKey(mk); + expect(a.wrappedMk).not.toBe(b.wrappedMk); + expect(b.wrappedMk).not.toBe(c.wrappedMk); + expect(a.wrapIv).not.toBe(b.wrapIv); + // All three still unwrap correctly + expect(Buffer.from(await unwrapMasterKey(a.wrappedMk, a.wrapIv)).toString('hex')).toBe( + Buffer.from(mk).toString('hex') + ); + expect(Buffer.from(await unwrapMasterKey(b.wrappedMk, b.wrapIv)).toString('hex')).toBe( + Buffer.from(mk).toString('hex') + ); + }); + + it('rejects a master key of the wrong length', async () => { + await expect(wrapMasterKey(new Uint8Array(16))).rejects.toThrow(/32-byte master key/); + await expect(wrapMasterKey(new Uint8Array(64))).rejects.toThrow(/32-byte master key/); + }); +}); + +describe('unwrapMasterKey error paths', () => { + it('throws on tampered ciphertext (auth tag mismatch)', async () => { + const mk = generateMasterKey(); + const { wrappedMk, wrapIv } = await wrapMasterKey(mk); + // Flip the last base64 character to corrupt the auth tag + const lastChar = wrappedMk.charAt(wrappedMk.length - 1); + const swapped = lastChar === 'A' ? 'B' : 'A'; + const tampered = wrappedMk.slice(0, -1) + swapped; + await expect(unwrapMasterKey(tampered, wrapIv)).rejects.toThrow(); + }); + + it('throws on a wrong-length IV', async () => { + const mk = generateMasterKey(); + const { wrappedMk } = await wrapMasterKey(mk); + const badIv = 'AAAAAAAA'; // 6 bytes after base64 decode + await expect(unwrapMasterKey(wrappedMk, badIv)).rejects.toThrow(/12-byte IV/); + }); + + it('throws when a different KEK was used to wrap', async () => { + // Wrap with the test KEK + const mk = generateMasterKey(); + const { wrappedMk, wrapIv } = await wrapMasterKey(mk); + + // Reload with a different KEK + _resetForTesting(); + const otherKek = 'IB8eHRwbGhkYFxYVFBMSERAPDg0MCwoJCAcGBQQDAgE='; + await loadKek(otherKek); + + await expect(unwrapMasterKey(wrappedMk, wrapIv)).rejects.toThrow(); + }); +}); diff --git a/services/mana-auth/src/services/encryption-vault/kek.ts b/services/mana-auth/src/services/encryption-vault/kek.ts new file mode 100644 index 000000000..7080b94bf --- /dev/null +++ b/services/mana-auth/src/services/encryption-vault/kek.ts @@ -0,0 +1,180 @@ +/** + * Key Encryption Key (KEK) loader and AES-GCM wrap/unwrap helpers. + * + * The KEK is a 32-byte AES-256 key loaded from the MANA_AUTH_KEK env + * var (base64). It wraps each user's master key (MK) before storage in + * `auth.encryption_vaults.wrapped_mk`. The KEK itself NEVER touches the + * database — it lives only in process memory and is sourced from a + * single environment variable that must be provisioned out of band + * (Docker secret, KMS-injected, etc.). + * + * Why a separate AES-GCM wrap instead of e.g. libsodium SecretBox? + * - Both Bun and the browser ship native Web Crypto AES-GCM, so the + * wire format is portable across the future "client-side wrap" + * scenario without bundling extra crypto deps. + * - The encryption-vault rows live behind row-level security and + * are never exposed; the threat model here is "what if an + * attacker dumps the auth DB?", which AES-GCM-256 with a 256-bit + * KEK fully addresses. + * + * Future migration to KMS / Vault: + * The KEK loader is a single function. When we move to AWS KMS or + * Hashicorp Vault, only `loadKek()` changes. The `wrapMasterKey` / + * `unwrapMasterKey` callers stay identical, and the wrapped_mk + * column gets a new `kek_id` value to mark which KEK produced it. + */ + +const KEK_LENGTH_BYTES = 32; // AES-256 +const IV_LENGTH_BYTES = 12; // AES-GCM standard +const MK_LENGTH_BYTES = 32; // user master key is also AES-256 + +let _kek: CryptoKey | null = null; +let _kekId: string | null = null; + +/** + * Loads the KEK from a base64 string and prepares it for use as an + * AES-GCM key. Idempotent: subsequent calls with the same string are + * no-ops. Throws if the input is not exactly 32 bytes after decoding. + * + * Call this once at boot from `index.ts` after `loadConfig()` has run. + */ +export async function loadKek(base64: string): Promise { + if (_kek) return; + + const raw = base64ToBytes(base64); + if (raw.length !== KEK_LENGTH_BYTES) { + throw new Error( + `mana-auth/kek: expected ${KEK_LENGTH_BYTES} bytes after base64 decode, got ${raw.length}. ` + + 'Generate a fresh key with `openssl rand -base64 32`.' + ); + } + + // Loud warning if the dev fallback KEK (32 zero bytes) is in use — + // catches accidental production deploys without a real secret. + if (raw.every((b) => b === 0)) { + console.warn( + '\n⚠️ mana-auth: USING DEV KEK (32 zero bytes). ' + + 'Set MANA_AUTH_KEK to a real value before production.\n' + ); + } + + _kek = await crypto.subtle.importKey( + 'raw', + toBufferSource(raw), + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); + + // kek_id format lets us distinguish env-loaded keys from future + // KMS-loaded ones at unwrap time. The `v1` suffix gives us a path + // for in-place rotation: a new KEK gets `env-v2`, old vault rows + // keep working until a background rotator re-wraps them. + _kekId = 'env-v1'; +} + +/** Returns the kek_id stamp written to encryption_vaults.kek_id. */ +export function activeKekId(): string { + if (!_kekId) throw new Error('mana-auth/kek: loadKek() not called yet'); + return _kekId; +} + +/** + * Wraps a 32-byte master key with the KEK. Returns the base64 IV and + * base64 ciphertext (which includes the 16-byte AES-GCM auth tag at + * the tail). Both pieces are written to `encryption_vaults`. + */ +export async function wrapMasterKey( + mkBytes: Uint8Array +): Promise<{ wrappedMk: string; wrapIv: string }> { + if (mkBytes.length !== MK_LENGTH_BYTES) { + throw new Error( + `mana-auth/kek: expected ${MK_LENGTH_BYTES}-byte master key, got ${mkBytes.length}` + ); + } + const kek = requireKek(); + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH_BYTES)); + const ct = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: toBufferSource(iv) }, + kek, + toBufferSource(mkBytes) + ); + return { + wrappedMk: bytesToBase64(new Uint8Array(ct)), + wrapIv: bytesToBase64(iv), + }; +} + +/** + * Unwraps a stored master key. Returns the raw 32 bytes ready to be + * sent to the client (over HTTPS) and re-imported as a CryptoKey by + * the browser. + * + * Throws on tampered ciphertext (auth tag mismatch), wrong IV length, + * wrong KEK, or any AES-GCM failure. The caller (vault service) + * surfaces these as 500s and writes a `failed_fetch` audit row. + */ +export async function unwrapMasterKey(wrappedMk: string, wrapIv: string): Promise { + const kek = requireKek(); + const iv = base64ToBytes(wrapIv); + if (iv.length !== IV_LENGTH_BYTES) { + throw new Error(`mana-auth/kek: expected ${IV_LENGTH_BYTES}-byte IV, got ${iv.length}`); + } + const ct = base64ToBytes(wrappedMk); + const plain = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: toBufferSource(iv) }, + kek, + toBufferSource(ct) + ); + const out = new Uint8Array(plain); + if (out.length !== MK_LENGTH_BYTES) { + throw new Error( + `mana-auth/kek: unwrapped key has wrong length ${out.length} (expected ${MK_LENGTH_BYTES})` + ); + } + return out; +} + +/** + * Generates a fresh 32-byte master key. Used by the vault service at + * vault initialisation time and during rotation. + */ +export function generateMasterKey(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(MK_LENGTH_BYTES)); +} + +// ─── Internals ──────────────────────────────────────────────── + +function requireKek(): CryptoKey { + if (!_kek) { + throw new Error('mana-auth/kek: loadKek() must be called before any wrap/unwrap operation'); + } + return _kek; +} + +function bytesToBase64(bytes: Uint8Array): string { + let bin = ''; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin); +} + +function base64ToBytes(b64: string): Uint8Array { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +/** TS 5.7 compat — Uint8Array isn't assignable to BufferSource. */ +function toBufferSource(bytes: Uint8Array): ArrayBuffer { + const buf = new ArrayBuffer(bytes.length); + new Uint8Array(buf).set(bytes); + return buf; +} + +// Test-only reset hook so vitest can reload the KEK between tests +// without re-running the whole module. Not exported from any barrel. +export function _resetForTesting(): void { + _kek = null; + _kekId = null; +}