docs: ADR for zero-knowledge encryption Phase 1
Records the decision: client-side AES-256-GCM, double-PBKDF2 auth/enc-key split, server-as-blind-store, in-memory key lifecycle, and Phase 1 limitations (forgotten password = data loss; Phase 2 recovery wrapping deferred).
This commit is contained in:
parent
1301473bfe
commit
d7d09482da
2 changed files with 70 additions and 0 deletions
69
docs/adr/zero-knowledge-encryption.md
Normal file
69
docs/adr/zero-knowledge-encryption.md
Normal file
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue