Records the implemented wrapped-DEK recovery model, new endpoints, and the password-change re-wrapping. Strikes the 'future' Phase 2 items as done.
90 lines
3.8 KiB
Markdown
90 lines
3.8 KiB
Markdown
# 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.
|