mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 12:01:24 +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
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 './api-keys';
|
||||
export * from './login-attempts';
|
||||
export * from './encryption-vaults';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue