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.
257 lines
11 KiB
Markdown
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`
|