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.
11 KiB
Zero-Knowledge Encryption
Status: Implemented (Phase 1 + Phase 2)
Last updated: 2026-07-18
Canonical ADR: docs/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/andbackend/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.tomlhas noaes,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 underKEK(password)recovery_wrapped_dek— DEK encrypted underKEK(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
- 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
- derives
- Client sends
{ email, password: authSecret, wrapped_dek, recovery_wrapped_dek?, recovery_phrase_hash? }toPOST /api/auth/register. - 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)
- Client derives
authSecret = PBKDF2(password, auth-salt)and sends it toPOST /api/auth/login. - Server verifies the auth secret against its stored hash and returns JWTs
plus the user's
password_wrapped_dek. - 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
- Client derives the old password KEK, unwraps the DEK.
- Client re-wraps the same DEK under the new password KEK (
rewrapDek). - Client sends the new wrapped DEK to the server. The DEK itself does not change, so existing data stays readable.
- Server bumps
token_version, invalidating all existing JWTs.
Password recovery (via recovery phrase)
- User enters email →
GET /api/auth/recovery-inforeturns therecovery_wrapped_dek. - User enters their recovery phrase → client derives the recovery KEK and unwraps the DEK from the recovery-wrapped form.
- User sets a new password → client re-wraps the DEK under the new password KEK.
- Client sends the new password-wrapped DEK to
POST /api/auth/recover-password. - 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_datablob) - Appointment data blob — title, provider, date/time, etc.
- Profile display name
Plaintext (server-visible, queryable)
- IDs:
medicationId,appointmentId,userId,profileId - Medication
activeflag (forGET /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.
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 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 - 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