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

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/ 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.

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