mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 21:46:43 +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
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue