managarten/services/mana-auth/src/config.ts
Till JS e9915428cb 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>
2026-04-07 18:38:09 +02:00

61 lines
2.3 KiB
TypeScript

export interface Config {
port: number;
databaseUrl: string;
syncDatabaseUrl: string;
baseUrl: string;
cookieDomain: string;
nodeEnv: string;
serviceKey: string;
cors: { origins: string[] };
manaNotifyUrl: string;
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'),
syncDatabaseUrl: env(
'SYNC_DATABASE_URL',
'postgresql://mana:devpassword@localhost:5432/mana_sync'
),
baseUrl: env('BASE_URL', 'http://localhost:3001'),
cookieDomain: env('COOKIE_DOMAIN'),
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,
};
}