diff --git a/docs/adr/README.md b/docs/adr/README.md index 5568fcc..b8b4e9f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,6 +18,7 @@ the documentation reconciliation. They are historical context, not current specs | [backend-deployment-constraints.md](./backend-deployment-constraints.md) | Deployment requirements (Solaria, Docker) | | [mobile-health-frameworks-data.md](./mobile-health-frameworks-data.md) | HealthKit / Health Connect data-type reference (for future mobile work) | | [android-health-connect-data-types.md](./android-health-connect-data-types.md) | Android Health Connect data types | +| [zero-knowledge-encryption.md](./zero-knowledge-encryption.md) | Client-side zero-knowledge encryption (AES-256-GCM, double-PBKDF2, Phase 1) | > **Note**: Where a decision diverges from the implemented code (e.g. state > management), the code is the source of truth and the ADR is kept only as diff --git a/docs/adr/zero-knowledge-encryption.md b/docs/adr/zero-knowledge-encryption.md new file mode 100644 index 0000000..2b6be96 --- /dev/null +++ b/docs/adr/zero-knowledge-encryption.md @@ -0,0 +1,69 @@ +# ADR: Zero-Knowledge Encryption (Phase 1) + +**Status**: Implemented (Phase 1) +**Date**: 2026-06-28 + +## 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). +- Password-change re-wrapping (re-wrap the enc key under the new password). +- Wrapped-DEK model (separate data-encryption key + key-encryption key). + +## 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.