normogen/docs/adr/zero-knowledge-encryption.md
goose 38bf0ae8b4
Some checks failed
Lint and Build / format (push) Successful in 33s
Lint and Build / clippy (push) Successful in 1m36s
Lint and Build / build (push) Successful in 3m37s
Lint and Build / test (push) Failing after 2m33s
docs: update ADR for ZK Phase 2 (wrapped-DEK recovery) completion
Records the implemented wrapped-DEK recovery model, new endpoints, and the
password-change re-wrapping. Strikes the 'future' Phase 2 items as done.
2026-06-29 08:51:03 -03:00

3.8 KiB

ADR: Zero-Knowledge Encryption (Phase 1)

Status: Implemented (Phase 1 + Phase 2) Date: 2026-06-28 (Phase 1), 2026-06-29 (Phase 2)

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). Done (Phase 2).
  • Password-change re-wrapping (re-wrap the enc key under the new password). Done (Phase 2).
  • Wrapped-DEK model (separate data-encryption key + key-encryption key). Done (Phase 2).

Phase 2: Wrapped-DEK Recovery (Implemented)

Phase 2 introduced the wrapped-DEK model so a forgotten password doesn't lose all encrypted data:

  • A random DEK (data encryption key) encrypts all user data (not a key derived directly from the password).
  • The DEK is wrapped (AES-GCM encrypted) under a password-derived KEK and a recovery-phrase-derived KEK. Both wrapped forms are stored on the server.
  • At login, the client derives the password KEK and unwraps the DEK.
  • At recovery, the client derives the recovery KEK, unwraps the DEK from the recovery-wrapped form, re-wraps it under the new password, and sends the new wrapped form to the server.
  • Password change also re-wraps the DEK under the new password.
  • New endpoints: GET /api/auth/recovery-info (returns the recovery-wrapped DEK), updated POST /api/auth/recover-password (accepts new wrapped DEK).
  • New frontend: RecoveryPage (email → recovery phrase → new password flow), recovery phrase field on RegisterPage.

The server never holds the DEK or any KEK — only the wrapped forms.

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.