mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:21:08 +02:00
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>
78 lines
3.7 KiB
SQL
78 lines
3.7 KiB
SQL
-- 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));
|