normogen/docs/product/encryption.md
goose e322145ffb
Some checks failed
Lint and Build / format (pull_request) Successful in 37s
Lint and Build / clippy (pull_request) Successful in 1m40s
Lint and Build / build (pull_request) Successful in 3m44s
Lint and Build / test (pull_request) Failing after 0s
docs: decide multi-person ZK sharing ADR; reconcile jwt/encryption docs
Rewrite the encryption/persona/JWT docs to match the implemented code and
record the multi-person sharing design.

- multi-person-sharing.md (NEW): ADR for per-profile DEK + X25519 envelope
  sharing. All five original open questions resolved 2026-07-18. Covers
  divorced-parents, pets, graduation, and elderly-parent care; adds an
  admin permission tier for owner-incapacity. Decided, not yet implemented.
  (#3)
- encryption.md: full rewrite to the implemented wrapped-DEK / Web Crypto
  model; the old version described ZK encryption as 'planned'. (#4)
- jwt-authentication-decision.md: rewritten to match implementation
  (PBKDF2 not bcrypt, no Redis, token_version revocation, real Claims).
  Original aspirational content preserved in a History section. (#4)
- PERSONA_AND_FAMILY_MANAGEMENT.md: 'Current reality' section added, old
  'Implementation Status' marked superseded, encryption section corrected. (#4)
- ENCRYPTION_UPDATE_SUMMARY.md: archived (changelog for a rewrite that
  itself went stale). (#4)
- ADR README index updated for the new + reconciled entries.
2026-07-18 19:26:43 -03:00

257 lines
11 KiB
Markdown

# Zero-Knowledge Encryption
**Status**: Implemented (Phase 1 + Phase 2)
**Last updated**: 2026-07-18
**Canonical ADR**: [`docs/adr/zero-knowledge-encryption.md`](../adr/zero-knowledge-encryption.md)
> The ADR is the source of truth for the design and its rationale. This document
> is the user-facing/product description of the same system, kept in sync with
> the code in `web/normogen-web/src/crypto/` and `backend/src/`.
---
## 1. The core property
Normogen is a **blind store**. The server holds opaque encrypted blobs and can
never read user health data. This is not a future goal — it is how the system
works today:
- All sensitive user data (medications, appointments, profile display names) is
**encrypted in the browser** before it leaves the client, using the Web Crypto
API.
- The encryption key is **derived from the user's password** in the browser and
**never transmitted** to the server.
- The backend has **no crypto dependencies** (`Cargo.toml` has no `aes`, `gcm`,
or similar crates). It stores and returns ciphertext verbatim.
This is the feature that differentiates Normogen from commercial health
platforms: even the operator of the server cannot read your data.
---
## 2. Key model (wrapped-DEK)
Two layers of keys:
```
┌─── password ───┐
│ │
PBKDF2(150k, SHA-256) │ │ PBKDF2(150k, SHA-256)
salt: normogen-auth-v1│ │ salt: normogen-kek-password-v1
▼ ▼
auth secret password KEK
(base64) (AES-GCM key, in-memory)
│ │
│ │ wraps/unwraps
│ ▼
│ ┌─── random DEK ────┐
│ │ AES-256-GCM key │ encrypts/decrypts
│ │ (in-memory only) │ all user data
│ └────────────────────┘
│ ▲
│ │ also wrapped under
│ │ recovery KEK (PBKDF2 of
│ │ recovery phrase)
sent to server both wrapped forms stored
as "the password" on the server (opaque blobs)
```
- **DEK (Data Encryption Key)** — a random 256-bit AES-GCM key generated at
registration. It directly encrypts all user data. Lives **only in browser
memory** for the session.
- **KEK (Key Encryption Key)** — derived from the password (or recovery phrase)
via PBKDF2. Used only to wrap (encrypt) the DEK so it can be stored on the
server.
- The server stores **two wrapped forms of the DEK**:
- `password_wrapped_dek` — DEK encrypted under `KEK(password)`
- `recovery_wrapped_dek` — DEK encrypted under `KEK(recovery phrase)`
- The server never holds the DEK itself, nor either KEK — only the wrapped
forms, which it cannot decrypt.
### Key derivation parameters
| Purpose | KDF | Salt (domain separator) | Iterations |
|---|---|---|---|
| Auth secret (sent to server) | PBKDF2-HMAC-SHA-256 | `normogen-auth-v1` | 150,000 |
| Password KEK | PBKDF2-HMAC-SHA-256 | `normogen-kek-password-v1` | 150,000 |
| Recovery KEK | PBKDF2-HMAC-SHA-256 | `normogen-kek-recovery-v1` | 150,000 |
Domain-separated salts mean the same password produces three independent,
non-reusable values.
> Reference: `web/normogen-web/src/crypto/keys.ts`.
---
## 3. Lifecycle
### Registration
1. Client runs `setupEncryption(password, recoveryPhrase?)`:
- derives `authSecret = PBKDF2(password, auth-salt)`
- generates a random DEK
- derives `passwordKEK = PBKDF2(password, kek-password-salt)`
- wraps the DEK under the password KEK → `passwordWrappedDek`
- (optional) wraps the DEK under the recovery KEK → `recoveryWrappedDek`
2. Client sends `{ email, password: authSecret, wrapped_dek, recovery_wrapped_dek?, recovery_phrase_hash? }` to `POST /api/auth/register`.
3. Server PBKDF2-hashes the auth secret (as it would any password) and stores
the wrapped-DEK blobs verbatim. A default `profile_{user_id}` profile is
created.
### Login (unlock)
1. Client derives `authSecret = PBKDF2(password, auth-salt)` and sends it to
`POST /api/auth/login`.
2. Server verifies the auth secret against its stored hash and returns JWTs
**plus the user's `password_wrapped_dek`**.
3. Client derives `passwordKEK`, **unwraps** the DEK, and holds it in memory.
Until this succeeds the user is authenticated but cannot read/write any
encrypted data — this is the "unlock" step (`UnlockPage.tsx`).
### Password change
1. Client derives the old password KEK, unwraps the DEK.
2. Client **re-wraps** the same DEK under the new password KEK (`rewrapDek`).
3. Client sends the new wrapped DEK to the server. The DEK itself does not
change, so existing data stays readable.
4. Server bumps `token_version`, invalidating all existing JWTs.
### Password recovery (via recovery phrase)
1. User enters email → `GET /api/auth/recovery-info` returns the
`recovery_wrapped_dek`.
2. User enters their recovery phrase → client derives the recovery KEK and
**unwraps the DEK** from the recovery-wrapped form.
3. User sets a new password → client **re-wraps** the DEK under the new
password KEK.
4. Client sends the new password-wrapped DEK to
`POST /api/auth/recover-password`.
5. Server increments `token_version` (all prior tokens invalidated).
This is the hard problem in zero-knowledge systems and it is solved here
without the server ever touching an unwrapped key.
---
## 4. What is encrypted vs. plaintext
The split is deliberate: enough stays plaintext for the server to query and
aggregate (filtering, adherence math); everything sensitive is ciphertext.
### Encrypted (client-side, under the DEK)
- Medication data blob — name, dosage, frequency, route, notes, etc. (the whole
record packed into one `encrypted_data` blob)
- Appointment data blob — title, provider, date/time, etc.
- Profile display name
### Plaintext (server-visible, queryable)
- IDs: `medicationId`, `appointmentId`, `userId`, `profileId`
- Medication `active` flag (for `GET /api/medications?active=true`)
- Appointment `status`
- `doseSchedule` (so the server can compute adherence from logged doses without
decrypting the medication record)
- Timestamps
### Wire shape
```
EncryptedField { data: base64, iv: base64, auth_tag: base64 }
```
For AES-256-GCM via Web Crypto the 12-byte IV is generated randomly per
encryption; the auth tag is appended to the ciphertext by `subtle.encrypt` and
validated on decrypt.
> Reference: `web/normogen-web/src/crypto/cipher.ts`, `backend/src/models/medication.rs` (`EncryptedFieldWire`).
---
## 5. In-memory key lifecycle (the ZK trade-off)
The DEK lives only in browser memory for the authenticated session. It is
**not persisted** to `localStorage`, `sessionStorage`, or a cookie.
Consequence: **a page reload requires re-entering the password** to re-derive
the key (the "unlock" screen). This is intentional. Convenience features that
would weaken this (e.g. "remember me" that persists the DEK) are out of scope
unless separately designed.
On logout / tab close, the key is cleared via `clearEncKey()`.
---
## 6. What is NOT yet solved (see issues)
### Multi-person / family / caregiver sharing — unsolved
Sharing a medication with a spouse or caregiver requires the **recipient** to
be able to decrypt it, but everything is encrypted under the **owner's** DEK.
There is currently no key-distribution mechanism (no asymmetric envelope, no
re-wrapping to a recipient key). The `Share` model and `/api/shares` routes
exist but cannot actually grant decryption access to another user.
This is a design problem, not a bug, and it blocks the family/caregiver
personas. See **[issue #3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3)**.
### Multi-profile within one account — partial
`profileId` is plaintext and queryable, so medications can be tagged to
different people under one account. But the only profile endpoint is
`GET/PUT /api/profiles/me` (single profile per user); there is no
create-child / list-profiles / switch flow yet. See
[`PERSONA_AND_FAMILY_MANAGEMENT.md`](PERSONA_AND_FAMILY_MANAGEMENT.md) for the
gap between vision and current implementation.
### Key rotation / re-encryption
There is no facility to rotate the DEK (re-encrypt all data under a new key)
or to migrate encryption parameters. Low priority until there's a reason.
---
## 7. Backend security (complementary, server-side)
These are the server-side defenses that sit alongside (not instead of) the
client-side ZK encryption. They protect the account and the ciphertext store,
not the plaintext (which the server never has).
| Feature | Implementation |
|---|---|
| Password hashing | PBKDF2 on the auth secret, salted |
| JWT access tokens | 15-minute expiry, claims `{sub, email, family_id, permissions, token_type, iat, exp, jti}` |
| JWT refresh tokens | 30-day expiry, stored in MongoDB, rotated on every refresh |
| Token revocation | `token_version` claim — bumped on password change/recovery, invalidates all outstanding tokens |
| Rate limiting | IP-based via `tower-governor` |
| Account lockout | 5 failed attempts → exponential backoff (15 min → 24 h max) |
| Session management | List / revoke sessions, revoke-all |
| Audit logging | Auth attempts, authorization checks, security events |
> Reference: `backend/src/auth/`, `backend/src/security/`, `backend/src/models/audit_log.rs`.
---
## 8. Threat model summary
| Threat | Mitigation |
|---|---|
| Server operator reads user data | Impossible — server only has ciphertext + wrapped keys; no DEK or KEK ever leaves the client |
| Database compromise | Ciphertext-only; data unreadable without a KEK, which requires the user's password |
| Stolen refresh token | Rotated on use; revocable via session list; `token_version` bump kills all tokens |
| Forgotten password | Recoverable via the recovery phrase (re-derives recovery KEK, unwraps DEK) |
| Lost recovery phrase **and** password | **Data is lost.** No escrow; by design. |
| Cross-user sharing | **Not yet supported** — see issue #3 |
| Replay/tamper of ciphertext | AES-GCM authenticated encryption detects tampering and wrong keys |
---
## References
- ADR (canonical): [`docs/adr/zero-knowledge-encryption.md`](../adr/zero-knowledge-encryption.md)
- Client crypto: `web/normogen-web/src/crypto/` (`keys.ts`, `cipher.ts`, `index.ts`)
- Client flows: `RegisterPage.tsx`, `UnlockPage.tsx`, `RecoveryPage.tsx`
- Server wire shape: `backend/src/models/medication.rs` (`EncryptedFieldWire`), `backend/src/models/profile.rs`, `backend/src/models/appointment.rs`
- User key storage: `backend/src/models/user.rs` (`wrapped_dek`, `recovery_wrapped_dek`)
- Routes: `backend/src/app.rs`