# ADR: Zero-Knowledge Encryption (Phase 1) **Status**: Implemented (Phase 1 + Phase 2) **Date**: 2026-06-28 (Phase 1), 2026-06-29 (Phase 2) ## Context The original design (`docs/product/encryption.md`, `docs/adr/mongodb-schema-decision.md`) specified client-side zero-knowledge encryption: the server should never hold plaintext user data or encryption keys. Until this change, the `EncryptedField` type and recovery-phrase fields were aspirational scaffolding — data was always stored and processed as plaintext JSON. ## Decision Implement **client-side zero-knowledge encryption** using AES-256-GCM via the browser's Web Crypto API. The server becomes a blind store: it holds opaque ciphertext and can never decrypt user data. ### Key derivation: double PBKDF2 From the user's password, the client derives two independent values: - **Auth secret**: `PBKDF2(password, "normogen-auth-v1", 150k iters, SHA-256)` → base64, sent to the server as the "password". The server PBKDF2-hashes it (as before). - **Encryption key**: `PBKDF2(password, "normogen-enc-v1", 150k iters, SHA-256)` → AES-GCM `CryptoKey`, kept in memory only, never transmitted. The server cannot derive the encryption key because it never sees the raw password or the enc-domain PBKDF2 output. ### What's encrypted - Medication data blobs (name, dosage, frequency, route, etc.) - Appointment data blobs (title, provider, date/time, etc.) - Profile display name ### What stays plaintext (queryable) - IDs (`medicationId`, `appointmentId`, `userId`, `profileId`) - Medication `active` flag (top-level, filterable) - Appointment `status` (top-level, filterable) - Timestamps ### In-memory key lifecycle The encryption key lives only in browser memory for the authenticated session. It is NOT persisted. A page reload requires re-entering the password to re-derive it. This is the core ZK trade-off. ## Consequences ### Phase 1 limitations (documented) - **Forgotten password = data loss.** No recovery key-wrapping yet (Phase 2). - **Page reload requires re-authentication** to re-derive the enc key. - No data migration was needed (no real user data existed). ### Phase 2 (future) - ~~Recovery key-wrapping (wrap the enc key under a recovery-derived key so a forgotten password doesn't lose data).~~ **Done (Phase 2).** - ~~Password-change re-wrapping (re-wrap the enc key under the new password).~~ **Done (Phase 2).** - ~~Wrapped-DEK model (separate data-encryption key + key-encryption key).~~ **Done (Phase 2).** ## Phase 2: Wrapped-DEK Recovery (Implemented) Phase 2 introduced the wrapped-DEK model so a forgotten password doesn't lose all encrypted data: - A **random DEK** (data encryption key) encrypts all user data (not a key derived directly from the password). - The DEK is **wrapped** (AES-GCM encrypted) under a password-derived KEK and a recovery-phrase-derived KEK. Both wrapped forms are stored on the server. - At login, the client derives the password KEK and **unwraps** the DEK. - At recovery, the client derives the recovery KEK, unwraps the DEK from the recovery-wrapped form, **re-wraps** it under the new password, and sends the new wrapped form to the server. - Password change also re-wraps the DEK under the new password. - New endpoints: `GET /api/auth/recovery-info` (returns the recovery-wrapped DEK), updated `POST /api/auth/recover-password` (accepts new wrapped DEK). - New frontend: `RecoveryPage` (email → recovery phrase → new password flow), recovery phrase field on `RegisterPage`. The server never holds the DEK or any KEK — only the wrapped forms. ## Relationship to the original design doc `docs/product/encryption.md` describes a fuller zero-knowledge architecture (per-user keys, deterministic encryption for search, key rotation). This Phase 1 implements the core property (server can't read data) with the simplest viable design. The fuller features remain future work.