mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
feat(mana-auth): encryption vault — phase 2 (server-side master key custody)
Adds the server side of the per-user encryption vault. Phase 1 shipped
the client foundation (no-op while every table is enabled:false). This
commit lets the client actually fetch a master key when Phase 3 flips
the registry switches.
Schema (Drizzle + raw SQL migration)
- auth.encryption_vaults: per-user wrapped MK + IV + format version +
kek_id stamp + created/rotated timestamps. PK = user_id, ON DELETE
CASCADE so account deletion wipes the vault.
- auth.encryption_vault_audit: append-only trail of init/fetch/rotate
actions with IP, user-agent, HTTP status, free-form context.
- sql/002_encryption_vaults.sql: idempotent CREATE TABLE + ENABLE +
FORCE row-level security with a `current_setting('app.current_user_id')`
policy on both tables. FORCE makes the policy apply to the table
owner too — no bypass via grants.
KEK loader (services/encryption-vault/kek.ts)
- Loads a 32-byte AES-256 KEK from the MANA_AUTH_KEK env var (base64).
- Production: missing or wrong-length input is fatal at boot.
- Development: 32-zero-byte fallback so contributors can run the
service without provisioning a secret. Logs a loud warning.
- wrapMasterKey / unwrapMasterKey use Web Crypto AES-GCM-256 over the
raw 32-byte MK with a fresh 12-byte IV per wrap. Returns base64
pair for storage.
- generateMasterKey + activeKekId helpers used by the service.
- Future migration to KMS / Vault: only loadKek() changes; the
kek_id stamp on each row tracks which KEK produced it.
EncryptionVaultService (services/encryption-vault/index.ts)
- init(userId): idempotent — returns existing MK or mints a new one.
- getMasterKey(userId): unwraps the stored MK; throws VaultNotFoundError
on no-row so the route can return 404 cleanly.
- rotate(userId): mints fresh MK, replaces wrap. Caller is on the
hook for re-encryption — destructive by design.
- withUserScope(userId, fn): wraps every read/write in a Drizzle
transaction with set_config('app.current_user_id', userId, true)
so the RLS policy admits only the matching row. Empty userId is
rejected up-front.
- writeAudit() appends a row to encryption_vault_audit on every
action including failures, so probing attempts leave a trail.
Routes (routes/encryption-vault.ts)
- POST /api/v1/me/encryption-vault/init — idempotent bootstrap
- GET /api/v1/me/encryption-vault/key — fetch the active MK
- POST /api/v1/me/encryption-vault/rotate — destructive rotation
- All return base64-encoded master key bytes plus formatVersion +
kekId. JWT-protected via the existing /api/v1/me/* middleware.
- readAuditContext() pulls X-Forwarded-For + User-Agent off the
request for the audit row.
Bootstrap (index.ts)
- loadKek() runs at top-level await before any route can fire so a
misconfigured KEK fails closed at boot, never at request time.
- encryptionVaultService is mounted under /api/v1/me/encryption-vault
so it inherits the existing JWT middleware and shows up next to the
GDPR self-service endpoints.
Tests (services/encryption-vault/kek.test.ts)
- 11 Bun-test cases covering: KEK load (happy path, wrong length,
idempotent, before-load guard), generateMasterKey randomness,
wrap/unwrap roundtrip, IV uniqueness across repeated wraps,
wrong-MK-length rejection, tampered-ciphertext rejection,
wrong-length IV rejection, wrong-KEK rejection.
- Service-level integration tests deferred — they need a real
Postgres for the RLS behaviour, set up via existing mana-sync
test pattern in CI.
Config + env
- .env.development gains MANA_AUTH_KEK= (empty → dev fallback)
with a comment explaining the production requirement.
- services/mana-auth/package.json gains "test": "bun test".
Verified: 11/11 KEK tests passing, 31/31 Phase 1 client tests still
passing, only pre-existing TS errors remain in mana-auth (auth.ts:281
forgetPassword + api-keys.ts:50 insert overload — both unrelated).
Phase 3: client wires the MemoryKeyProvider to GET /encryption-vault/key
on login, flips registry entries to enabled:true table by table, and
extends the Dexie hooks to call wrapValue/unwrapValue on configured
fields.
Phase 4: settings UI for lock state, key rotation, recovery code opt-in.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3a4c6654b5
commit
e9915428cb
11 changed files with 913 additions and 5 deletions
|
|
@ -64,6 +64,15 @@ S3_SECRET_KEY=minioadmin
|
||||||
|
|
||||||
MANA_AUTH_PORT=3001
|
MANA_AUTH_PORT=3001
|
||||||
MANA_AUTH_DATABASE_URL=postgresql://mana:devpassword@localhost:5432/mana_platform
|
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_ACCESS_TOKEN_EXPIRY=15m
|
||||||
JWT_REFRESH_TOKEN_EXPIRY=7d
|
JWT_REFRESH_TOKEN_EXPIRY=7d
|
||||||
JWT_ISSUER=mana
|
JWT_ISSUER=mana
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --watch src/index.ts",
|
"dev": "bun run --watch src/index.ts",
|
||||||
"start": "bun run src/index.ts",
|
"start": "bun run src/index.ts",
|
||||||
|
"test": "bun test",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio"
|
||||||
|
|
|
||||||
78
services/mana-auth/sql/002_encryption_vaults.sql
Normal file
78
services/mana-auth/sql/002_encryption_vaults.sql
Normal file
|
|
@ -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));
|
||||||
|
|
@ -11,28 +11,51 @@ export interface Config {
|
||||||
manaCreditsUrl: string;
|
manaCreditsUrl: string;
|
||||||
manaSubscriptionsUrl: string;
|
manaSubscriptionsUrl: string;
|
||||||
synapseOidcClientSecret: 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 {
|
export function loadConfig(): Config {
|
||||||
const env = (key: string, fallback?: string) => process.env[key] || fallback || '';
|
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 {
|
return {
|
||||||
port: parseInt(env('PORT', '3001'), 10),
|
port: parseInt(env('PORT', '3001'), 10),
|
||||||
databaseUrl: env(
|
databaseUrl: env('DATABASE_URL', 'postgresql://mana:devpassword@localhost:5432/mana_platform'),
|
||||||
'DATABASE_URL',
|
|
||||||
'postgresql://mana:devpassword@localhost:5432/mana_platform'
|
|
||||||
),
|
|
||||||
syncDatabaseUrl: env(
|
syncDatabaseUrl: env(
|
||||||
'SYNC_DATABASE_URL',
|
'SYNC_DATABASE_URL',
|
||||||
'postgresql://mana:devpassword@localhost:5432/mana_sync'
|
'postgresql://mana:devpassword@localhost:5432/mana_sync'
|
||||||
),
|
),
|
||||||
baseUrl: env('BASE_URL', 'http://localhost:3001'),
|
baseUrl: env('BASE_URL', 'http://localhost:3001'),
|
||||||
cookieDomain: env('COOKIE_DOMAIN'),
|
cookieDomain: env('COOKIE_DOMAIN'),
|
||||||
nodeEnv: env('NODE_ENV', 'development'),
|
nodeEnv,
|
||||||
serviceKey: env('MANA_SERVICE_KEY', 'dev-service-key'),
|
serviceKey: env('MANA_SERVICE_KEY', 'dev-service-key'),
|
||||||
cors: { origins: env('CORS_ORIGINS', 'http://localhost:5173').split(',') },
|
cors: { origins: env('CORS_ORIGINS', 'http://localhost:5173').split(',') },
|
||||||
manaNotifyUrl: env('MANA_NOTIFY_URL', 'http://localhost:3013'),
|
manaNotifyUrl: env('MANA_NOTIFY_URL', 'http://localhost:3013'),
|
||||||
manaCreditsUrl: env('MANA_CREDITS_URL', 'http://localhost:3061'),
|
manaCreditsUrl: env('MANA_CREDITS_URL', 'http://localhost:3061'),
|
||||||
manaSubscriptionsUrl: env('MANA_SUBSCRIPTIONS_URL', 'http://localhost:3063'),
|
manaSubscriptionsUrl: env('MANA_SUBSCRIPTIONS_URL', 'http://localhost:3063'),
|
||||||
synapseOidcClientSecret: env('SYNAPSE_OIDC_CLIENT_SECRET'),
|
synapseOidcClientSecret: env('SYNAPSE_OIDC_CLIENT_SECRET'),
|
||||||
|
encryptionKek,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
100
services/mana-auth/src/db/schema/encryption-vaults.ts
Normal file
100
services/mana-auth/src/db/schema/encryption-vaults.ts
Normal file
|
|
@ -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;
|
||||||
|
|
@ -2,3 +2,4 @@ export * from './auth';
|
||||||
export * from './organizations';
|
export * from './organizations';
|
||||||
export * from './api-keys';
|
export * from './api-keys';
|
||||||
export * from './login-attempts';
|
export * from './login-attempts';
|
||||||
|
export * from './encryption-vaults';
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,13 @@ import { SecurityEventsService, AccountLockoutService } from './services/securit
|
||||||
import { SignupLimitService } from './services/signup-limit';
|
import { SignupLimitService } from './services/signup-limit';
|
||||||
import { ApiKeysService } from './services/api-keys';
|
import { ApiKeysService } from './services/api-keys';
|
||||||
import { UserDataService } from './services/user-data';
|
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 { createAuthRoutes } from './routes/auth';
|
||||||
import { createGuildRoutes } from './routes/guilds';
|
import { createGuildRoutes } from './routes/guilds';
|
||||||
import { createApiKeyRoutes, createApiKeyValidationRoute } from './routes/api-keys';
|
import { createApiKeyRoutes, createApiKeyValidationRoute } from './routes/api-keys';
|
||||||
import { createMeRoutes } from './routes/me';
|
import { createMeRoutes } from './routes/me';
|
||||||
|
import { createEncryptionVaultRoutes } from './routes/encryption-vault';
|
||||||
import { createSettingsRoutes } from './routes/settings';
|
import { createSettingsRoutes } from './routes/settings';
|
||||||
import { createAdminRoutes } from './routes/admin';
|
import { createAdminRoutes } from './routes/admin';
|
||||||
|
|
||||||
|
|
@ -30,12 +33,18 @@ const config = loadConfig();
|
||||||
const db = getDb(config.databaseUrl);
|
const db = getDb(config.databaseUrl);
|
||||||
const auth = createBetterAuth(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
|
// Initialize services
|
||||||
const security = new SecurityEventsService(db);
|
const security = new SecurityEventsService(db);
|
||||||
const lockout = new AccountLockoutService(db);
|
const lockout = new AccountLockoutService(db);
|
||||||
const signupLimit = new SignupLimitService(db);
|
const signupLimit = new SignupLimitService(db);
|
||||||
const apiKeysService = new ApiKeysService(db);
|
const apiKeysService = new ApiKeysService(db);
|
||||||
const userDataService = new UserDataService(db, config);
|
const userDataService = new UserDataService(db, config);
|
||||||
|
const encryptionVaultService = new EncryptionVaultService(db);
|
||||||
|
|
||||||
// ─── App ────────────────────────────────────────────────────
|
// ─── App ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -83,6 +92,11 @@ app.route('/api/v1/api-keys', createApiKeyValidationRoute(apiKeysService));
|
||||||
app.use('/api/v1/me/*', jwtAuth(config.baseUrl));
|
app.use('/api/v1/me/*', jwtAuth(config.baseUrl));
|
||||||
app.route('/api/v1/me', createMeRoutes(userDataService));
|
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 ──────────────────────────────────────────────
|
// ─── Settings ──────────────────────────────────────────────
|
||||||
|
|
||||||
app.use('/api/v1/settings/*', jwtAuth(config.baseUrl));
|
app.use('/api/v1/settings/*', jwtAuth(config.baseUrl));
|
||||||
|
|
|
||||||
119
services/mana-auth/src/routes/encryption-vault.ts
Normal file
119
services/mana-auth/src/routes/encryption-vault.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
249
services/mana-auth/src/services/encryption-vault/index.ts
Normal file
249
services/mana-auth/src/services/encryption-vault/index.ts
Normal file
|
|
@ -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<VaultFetchResult> {
|
||||||
|
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<VaultFetchResult> {
|
||||||
|
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<VaultFetchResult> {
|
||||||
|
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<T>(
|
||||||
|
userId: string,
|
||||||
|
fn: (tx: Parameters<Parameters<Database['transaction']>[0]>[0]) => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
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<Parameters<Database['transaction']>[0]>[0],
|
||||||
|
userId: string,
|
||||||
|
action: 'init' | 'fetch' | 'rotate' | 'failed_fetch',
|
||||||
|
ctx: AuditContext,
|
||||||
|
status: number,
|
||||||
|
context: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
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 };
|
||||||
134
services/mana-auth/src/services/encryption-vault/kek.test.ts
Normal file
134
services/mana-auth/src/services/encryption-vault/kek.test.ts
Normal file
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
180
services/mana-auth/src/services/encryption-vault/kek.ts
Normal file
180
services/mana-auth/src/services/encryption-vault/kek.ts
Normal file
|
|
@ -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<void> {
|
||||||
|
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<Uint8Array> {
|
||||||
|
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<ArrayBufferLike> 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue