From e322145ffb74826c9ff66e856499e1b8fdca8018 Mon Sep 17 00:00:00 2001 From: goose Date: Sat, 18 Jul 2026 19:26:43 -0300 Subject: [PATCH] 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. --- docs/adr/README.md | 5 +- docs/adr/jwt-authentication-decision.md | 293 ++--- docs/adr/multi-person-sharing.md | 336 ++++++ .../ENCRYPTION_UPDATE_SUMMARY.md | 8 + docs/product/PERSONA_AND_FAMILY_MANAGEMENT.md | 66 +- docs/product/encryption.md | 1019 +++-------------- 6 files changed, 737 insertions(+), 990 deletions(-) create mode 100644 docs/adr/multi-person-sharing.md rename docs/{product => archive}/ENCRYPTION_UPDATE_SUMMARY.md (85%) diff --git a/docs/adr/README.md b/docs/adr/README.md index b8b4e9f..1759ef1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -11,14 +11,15 @@ the documentation reconciliation. They are historical context, not current specs |--------|-------| | [tech-stack-decision.md](./tech-stack-decision.md) | Master stack choice: Rust/Axum backend, React frontend, MongoDB, JWT | | [mongodb-schema-decision.md](./mongodb-schema-decision.md) | Document model + at-rest encryption approach | -| [jwt-authentication-decision.md](./jwt-authentication-decision.md) | JWT access/refresh tokens, recovery phrases | +| [jwt-authentication-decision.md](./jwt-authentication-decision.md) | JWT access/refresh tokens, `token_version` revocation, refresh rotation — *reconciled with code 2026-07-18* | | [frontend-decision-summary.md](./frontend-decision-summary.md) | React (web) + React Native (mobile, future) split | | [state-management-decision.md](./state-management-decision.md) | Client state — *superseded*: decision was Redux Toolkit, **actual code uses Zustand** | | [monorepo-structure.md](./monorepo-structure.md) | Repository layout (`backend/`, `web/`, `mobile/`, `docs/`) | | [backend-deployment-constraints.md](./backend-deployment-constraints.md) | Deployment requirements (Solaria, Docker) | | [mobile-health-frameworks-data.md](./mobile-health-frameworks-data.md) | HealthKit / Health Connect data-type reference (for future mobile work) | | [android-health-connect-data-types.md](./android-health-connect-data-types.md) | Android Health Connect data types | -| [zero-knowledge-encryption.md](./zero-knowledge-encryption.md) | Client-side zero-knowledge encryption (AES-256-GCM, double-PBKDF2, Phase 1) | +| [zero-knowledge-encryption.md](./zero-knowledge-encryption.md) | Client-side zero-knowledge encryption (AES-256-GCM, double-PBKDF2, wrapped-DEK recovery; Phase 1 + Phase 2 implemented) | +| [multi-person-sharing.md](./multi-person-sharing.md) | Per-profile DEK + X25519 envelope for family/caregiver sharing — *Decided, not yet implemented* (issue #3) | > **Note**: Where a decision diverges from the implemented code (e.g. state > management), the code is the source of truth and the ADR is kept only as diff --git a/docs/adr/jwt-authentication-decision.md b/docs/adr/jwt-authentication-decision.md index bf3f59b..b8d2190 100644 --- a/docs/adr/jwt-authentication-decision.md +++ b/docs/adr/jwt-authentication-decision.md @@ -1,174 +1,185 @@ -# JWT Authentication Decision Summary +# ADR: JWT Authentication -**Date**: 2026-02-14 -**Decision**: **JWT with Refresh Tokens + Recovery Phrases** +**Status**: Implemented (current code is the source of truth) +**Date**: 2026-02-14 (original decision), 2026-07-18 (reconciled with code) ---- +> This ADR was originally written during Phase 1 research and described several +> things that were never built (bcrypt, Redis, family-role permission claims in +> the JWT, the specific recovery-phrase flow). It has been rewritten to match +> the implementation. The original reasoning is preserved in the "History" +> section at the end. Where code and the old text disagree, **the code wins**. -## Authentication Strategy +## Context -### Primary: JWT (JSON Web Tokens) +Normogen authenticates accounts with email + password and authorizes API +requests with stateless JWTs. This sits *underneath* the zero-knowledge +encryption layer (see [`zero-knowledge-decryption.md`](./zero-knowledge-encryption.md) +and [`encryption.md`](../product/encryption.md)): authentication proves who you +are and lets you fetch your wrapped DEK; the client-side ZK layer then unlocks +your data. The auth system never sees plaintext health data. -**Why JWT?** -- Stateless design scales to 1000+ concurrent connections -- Works perfectly with mobile apps (AsyncStorage) -- No server-side session storage needed -- Easy to scale Axum horizontally +## Decision -### Token Types +### Token model -**Access Token** (15 minutes) -- Used for API requests -- Short-lived for security -- Contains: user_id, email, family_id, permissions +Two JWTs, both HS256-signed with a shared secret from config: -**Refresh Token** (30 days) -- Used to get new access tokens -- Long-lived for convenience -- Stored in MongoDB for revocation -- Rotated on every refresh +| Token | Lifetime | Claims | Purpose | +|---|---|---|---| +| Access | configurable (`JwtConfig.access_token_expiry_minutes`, default **15 min**) | `{sub, exp, iat, user_id, email, token_version}` | Sent on every API request | +| Refresh | configurable (`JwtConfig.refresh_token_expiry_days`, default **30 days**) | `{sub, exp, iat, jti, user_id, token_version}` | Exchange for a new access token | ---- +> Reference: `backend/src/auth/jwt.rs` (`Claims`, `RefreshClaims`, `JwtService`). -## Token Revocation Strategies +The lifetimes are **config-driven**, not hardcoded — the original ADR's "15 min +/ 30 days" are the defaults, not constants. -### 1. Refresh Token Blacklist (Recommended) ⭐ -- Store refresh tokens in MongoDB -- Mark as revoked on logout -- Check on every refresh +### Claims (actual, as implemented) -### 2. Token Versioning -- Include version in JWT claims -- Increment on password change -- Invalidate all tokens when version changes +The earlier ADR listed richer claims (`family_id`, `permissions`, `token_type`). +**These are not in the shipped access token.** The real `Claims` struct is: -### 3. Access Token Blacklist (Optional) -- Store revoked access tokens in Redis -- For immediate revocation -- Auto-expires with TTL +```rust +pub struct Claims { // access token + pub sub: String, // = user_id (ObjectId hex) + pub exp: usize, + pub iat: usize, + pub user_id: String, + pub email: String, + pub token_version: i32, +} ---- - -## Refresh Token Pattern - -### Token Rotation (Security Best Practice) ⭐ - -**Flow**: -1. Client sends refresh_token -2. Server verifies refresh_token (not revoked, not expired) -3. Server generates new access_token -4. Server generates new refresh_token -5. Server revokes old refresh_token -6. Server returns new tokens - -**Why?** Prevents reuse of stolen refresh tokens - ---- - -## Zero-Knowledge Password Recovery - -### Recovery Phrases (from encryption.md) - -**Registration**: -1. Client generates recovery phrase (random 32 bytes) -2. Client encrypts recovery phrase with password -3. Client sends: email, password hash, encrypted recovery phrase -4. Server stores: email, password hash, encrypted recovery phrase - -**Password Recovery**: -1. User requests recovery (enters email) -2. Server returns: encrypted recovery phrase -3. Client decrypts with recovery key (user enters manually) -4. User enters new password -5. Client re-encrypts recovery phrase with new password -6. Client sends: new password hash, re-encrypted recovery phrase -7. Server updates: password hash, encrypted recovery phrase, token_version + 1 -8. All existing tokens invalidated (version mismatch) - ---- - -## Family Member Access Control - -### Permissions in JWT - -```typescript -// JWT permissions based on family role -{ - "parent": [ - "read:own_data", - "write:own_data", - "read:family_data", - "write:family_data", - "manage:family_members", - "delete:data" - ], - "child": [ - "read:own_data", - "write:own_data" - ], - "elderly": [ - "read:own_data", - "write:own_data", - "read:family_data" - ] +pub struct RefreshClaims { // refresh token + pub sub: String, + pub exp: usize, + pub iat: usize, + pub jti: String, // unique per token (see below) + pub user_id: String, + pub token_version: i32, } ``` -### Permission Middleware +Note what is **absent** and why: -- Check permissions on protected routes -- Return 403 Forbidden if insufficient permissions -- Works with JWT claims +- **No `family_id` or `permissions` in the token.** Family/permission + enforcement is not implemented at the auth layer (see issue #3 — multi-person + sharing is an open design problem). Putting unenforced claims in the token + would imply protection that doesn't exist. + - `backend/src/auth/claims.rs` *does* define an `AccessClaims` struct with + `family_id`, `permissions`, `token_type`, `jti`. **It is dead code** — not + imported or used anywhere in the source (only in stale compiler scratch + files under `target/`). It should be removed or wired up; tracked by issue #4. +- **No `token_type` discriminator.** Access and refresh claims are different + structs, so a token can only decode as one or the other — the field is + redundant and was dropped. ---- +### Refresh tokens: persistence, rotation, reuse detection -## Technology Stack +- Refresh tokens are **stored in MongoDB** (`refresh_tokens` collection) — not + Redis (the original ADR listed Redis as an option; it is not used). +- The `jti` claim makes every refresh token unique. Without it, two refresh + tokens issued in the same second for the same user would be byte-identical, + which breaks rotation and reuse detection. (Covered by the + `refresh_tokens_are_unique_even_in_same_second` test in `jwt.rs`.) +- On refresh: validate signature + expiry, confirm the token exists and is not + revoked in the repository, mint a new access + refresh pair, revoke the old + refresh token (rotation). -### Backend (Axum) -- jsonwebtoken 9.x (JWT crate) -- bcrypt 0.15 (password hashing) -- mongodb 3.0 (refresh token storage) -- redis (optional, for access token blacklist) +### Token revocation: versioning + session management -### Client (React Native + React) -- AsyncStorage (token storage) -- axios (API client with JWT interceptor) -- PBKDF2 (password derivation) -- AES-256-GCM (data encryption) +Two mechanisms, both implemented: ---- +1. **`token_version` (global kill-switch).** Stored on the `User` document, + incremented on password change and password recovery. The JWT middleware + (`middleware/auth.rs`) rejects any access token whose `token_version` is + stale against the user's current value. A short-lived `TokenVersionCache` + avoids hitting Mongo on every request. +2. **Session management.** Refresh tokens are tracked as sessions + (`GET /api/sessions`, `DELETE /api/sessions/:id`, `DELETE /api/sessions/all`) + so a user can list and revoke individual logins. -## Implementation Timeline +### Password handling -- **Week 1**: Basic JWT (login, register, middleware) -- **Week 1-2**: Refresh tokens (storage, rotation) -- **Week 2**: Token revocation (blacklist, versioning) -- **Week 2-3**: Password recovery (recovery phrases) -- **Week 3**: Family access control (permissions) -- **Week 3-4**: Security hardening (rate limiting, HTTPS) +- **PBKDF2** (not bcrypt — the original ADR mentioned bcrypt), via the `pbkdf2` + crate with a per-password random salt. (`backend/src/auth/password.rs`) +- Under the ZK model, what the server hashes is the **auth secret** — a + base64'd PBKDF2 derivation of the user's password produced client-side — not + the raw password. The raw password never leaves the browser. See + [`encryption.md`](../product/encryption.md) §2. -**Total**: 3-4 weeks +### Password recovery ---- +Recovery is handled by the **zero-knowledge wrapped-DEK** mechanism, *not* by +the "client encrypts the recovery phrase with the password" scheme the original +ADR described. The client derives a recovery KEK from the recovery phrase, +unwraps the DEK from the recovery-wrapped form, and re-wraps it under the new +password. See [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md) +§"Phase 2: Wrapped-DEK Recovery". The server bumps `token_version` on recovery, +invalidating all prior tokens. -## Next Steps +### What protects what -1. Implement basic JWT service in Axum -2. Create MongoDB schema for users and refresh tokens -3. Implement login/register/refresh/logout handlers -4. Create JWT middleware for protected routes -5. Implement token revocation (blacklist + versioning) -6. Integrate password recovery (from encryption.md) -7. Implement family access control (permissions) -8. Test entire authentication flow -9. Create client-side authentication (React Native + React) +Auth + the ciphertext store are defended server-side; plaintext health data is +never on the server to protect: ---- +| Layer | Mechanism | +|---|---| +| Password / auth secret | PBKDF2 + per-password salt | +| Tokens | HS256 signature, short access TTL, refresh rotation + `jti` reuse detection | +| Revocation | `token_version` (global) + session list (per-login) | +| Brute force | Account lockout (5 attempts → exponential backoff) + rate limiting | +| Forensics | Audit logging (auth attempts, authz checks) | + +> Reference: `backend/src/auth/`, `backend/src/security/`, `backend/src/middleware/`. + +## Consequences + +### Positive + +- Stateless access tokens scale horizontally (no DB lookup per request except + the cached version check). +- `token_version` gives instant global revocation on password change/recovery + without maintaining a blocklist of outstanding tokens. +- Refresh rotation + `jti` detects token theft: a rotated-away token being + presented again signals reuse. + +### Negative / costs + +- `token_version` revocation is **coarse** — it kills *all* of a user's + sessions at once. Per-session revocation is only available via the session + list (refresh-token deletion). +- HS256 with a shared secret means the secret is sensitive infrastructure; a + key rotation requires invalidating all tokens (acceptable for the deployment + model, but worth noting vs. asymmetric RS/ES keys). +- The dead `AccessClaims` struct is a latent trap — someone could wire it up + assuming `family_id`/`permissions` are enforced. Should be deleted. + +## Open items + +- **Remove `backend/src/auth/claims.rs`** (dead `AccessClaims`) or wire it up + intentionally. Tracked under issue #4. +- When issue #3 (multi-person sharing) is decided, revisit whether family / + share authorization belongs in the JWT or is enforced per-request against a + shares collection. + +## History (original 2026-02-14 decision, superseded where noted) + +The original ADR proposed: JWT with refresh tokens; bcrypt; optional Redis for +access-token blacklists; recovery by client-encrypting the recovery phrase with +the password; and `family_id`/`permissions` claims with parent/child/elderly +role matrices. Of these, only **JWT with refresh tokens + rotation** was built +as described. Password hashing uses PBKDF2 (not bcrypt), there is no Redis, +recovery uses the wrapped-DEK scheme (not phrase-encryption), and the family/ +permission claims were never wired into the real `Claims`. This section is kept +as a record of the original reasoning; the sections above are authoritative. ## References -- [Comprehensive JWT Research](./2026-02-14-jwt-authentication-research.md) -- [Normogen Encryption Guide](../encryption.md) -- [JWT RFC 7519](https://tools.ietf.org/html/rfc7519) -- [Axum JWT Guide](https://docs.rs/axum/latest/axum/) -- [OWASP JWT Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html) +- Code: `backend/src/auth/jwt.rs`, `backend/src/auth/password.rs`, + `backend/src/middleware/auth.rs`, `backend/src/auth/token_version_cache.rs`, + `backend/src/models/refresh_token.rs`, `backend/src/models/session.rs` +- Related ADRs: [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md), + [`multi-person-sharing.md`](./multi-person-sharing.md) (draft) +- Product doc: [`encryption.md`](../product/encryption.md) +- Related issues: [#3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3), + [#4](https://gitea.soliverez.com.ar/alvaro/normogen/issues/4) diff --git a/docs/adr/multi-person-sharing.md b/docs/adr/multi-person-sharing.md new file mode 100644 index 0000000..e2e1825 --- /dev/null +++ b/docs/adr/multi-person-sharing.md @@ -0,0 +1,336 @@ +# ADR: Multi-Person Sharing Under Zero-Knowledge + +**Status**: Decided (not yet implemented) +**Date**: 2026-07-18 +**Discussion**: [issue #3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3) +**Related**: [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md) (the single-user ZK model this extends) + +> Records the design decision and its rationale ahead of implementation, +> following the pattern of `zero-knowledge-encryption.md`. The five original +> open questions were resolved on 2026-07-18 (see "Open questions" section). +> Two deferred items remain (pubkey verification mechanism; admin re-key +> quorum) — neither blocks implementation. All crypto primitives named here +> are available in the browser's Web Crypto API. + +--- + +## Context + +Normogen's core product vision (see +[`docs/product/PERSONA_AND_FAMILY_MANAGEMENT.md`](../product/PERSONA_AND_FAMILY_MANAGEMENT.md)) +is multi-person health data: a parent manages a child's medications and shares +them with a spouse or caregiver. The existing zero-knowledge model +(`zero-knowledge-encryption.md`) is single-user: **all of a user's data is +encrypted under one DEK that only that user can unwrap.** There is no mechanism +for a second user to decrypt any of it. As called out in issue #3, the current +`Share` model + `/api/shares` routes store ACL-style permission records but +cannot grant decryption access to another account — every medication blob is +under the owner's DEK, and the recipient has a different one. + +The product requirements that constrain this design (confirmed in issue #3): + +1. **Parents and spouses have separate logins**, and may belong to *different* + families. Divorced parents with their own families must be able to share the + profile of their common child **and nothing else**. +2. **Recipients always have Normogen accounts.** (One-way document dumps to + non-accounts — e.g. a physician via an encrypted PDF — is a separate, + simpler feature, out of scope here.) +3. **Revocation is a hard requirement.** Cutting off a caregiver must take + effect. +4. **The server operator is fully untrusted.** Key exchange cannot rely on the + server brokering symmetric secrets. + +Additional use cases from issue #3 that the model must accommodate: + +- **Profile graduation.** A child profile eventually becomes an independent + user with their own login, who may then revoke the parents' access. +- **Pets.** A profile may be an animal (medications, labs, etc.), not a human. + +## Decision + +Implement **per-profile DEKs with an X25519 asymmetric envelope** for sharing, +keeping the server a fully blind store. Concretely: + +### 1. Key hierarchy + +``` +password KEK ──wraps──▶ account DEK ──wraps each──▶ profile DEK ──encrypts──▶ profile data + │ (one per profile) + └──also wraps──▶ account X25519 private key + (account X25519 public key stored plaintext) +``` + +- **Account DEK**: random AES-256-GCM key, wrapped under the password KEK + (unchanged from today). At login the client unwraps it and holds it in + memory. +- **Profile DEK**: a *new* random AES-256-GCM key **per profile**. All data + belonging to a profile (its medications, appointments, etc.) is encrypted + under the profile DEK, not the account DEK. Each profile DEK is wrapped under + the account DEK and stored on the server, indexed by `profileId`. +- **Account X25519 keypair**: generated at registration. The **private** half + is wrapped under the account DEK (so it unlocks at login); the **public** + half is stored in plaintext — public keys are not secret. + +This adds one tier to today's flat `account DEK → data`. Rationale in +Consequences (§3-tier vs flat). + +### 2. Ownership and access + +- Each **profile** has exactly one **owning account** at a time. The owner + holds the profile DEK wrapped to their account DEK (the *owner share*). +- **Shared access** = the profile DEK wrapped to another account's X25519 + public key. Stored on the server as a *recipient share*, indexed by + `(profileId, recipientUserId)`. +- To **read** a shared profile, the recipient's client (at login) unwraps their + X25519 private key, ECDH-derives a wrapping key against the share's ephemeral + public key, and unwraps the profile DEK. + +This is the standard asymmetric-envelope pattern (cf. Signal, `age`, +Tarsnap). The server only ever stores wrapped blobs and public keys. + +### 3. Sharing flow (owner → recipient) + +Client-side only, server untrusted: + +1. Owner's client looks up the **recipient's X25519 public key** + (`GET /api/users/:id/public-key`, new). +2. Owner's client unwraps the target profile's DEK (via their account DEK). +3. Generate an **ephemeral X25519 keypair** for this share. +4. ECDH(ephemeral priv, recipient pub) → AES-GCM **wrapping key**. +5. Wrap the profile DEK under that wrapping key → `{ciphertext, iv, ephemeralPubKey}`. +6. `POST /api/profiles/:id/shares` with `{recipientUserId, wrappedDek, ephemeralPubKey, permissions, expiresAt?}`. +7. Server stores the share record. It cannot decrypt it. + +### 4. Revocation + +Two layers, in order of strength: + +- **Soft revoke** (cheap): delete or deactivate the recipient share record. The + recipient's client no longer receives the wrapped DEK from the server, so it + cannot decrypt **new reads**. Caveat: a malicious client that cached the + profile DEK before revocation keeps decrypting data it already fetched. +- **Hard revoke / re-key** (strong): owner generates a **new profile DEK**, + re-encrypts all of the profile's data under it, re-wraps to the owner share + and to all *still-valid* recipient shares. A revoked recipient's cached old + DEK now fails to decrypt any future fetch. Required for the "kid revokes + parent" graduation case where you must assume the parent may have cached + data. + +Hard revocation is the answer to requirement (3). Re-keying is expensive +(linear in profile data size) so it is opt-in / triggered explicitly, not the +default for every revoke. + +### 5. Profile model changes + +To support pets and graduation, the current `role: "patient"` string becomes: + +``` +kind: "human" | "pet" // distinguishes people from animals +relationship: string // freeform: "self", "child", "spouse", "pet", ... +``` + +Ownership-transfer (graduation) is **not new crypto** — it is the existing +share machinery plus an `owner_account_id` field swap: + +1. New adult account (the graduated child) generates its X25519 keypair. +2. Old owner wraps the profile DEK to the new account's pubkey as a share, and + marks that share as the new **owner share**. +3. Old owner's access is downgraded to (or removed as) a normal recipient share. +4. The new owner can revoke any remaining parent shares via soft/hard revoke. + +### 5b. Use case: elderly parent cared for by children + +A key motivation for the profile/account split is that **profile ownership and +account login are independent.** The elderly-care scenario has three setups, +all covered: + +- **Parent self-manages, shares to children.** Parent owns their own account + and `self` profile; creates recipient shares to each child. Standard + share-to-another-account flow. Any party can revoke. +- **Managing child holds a dedicated parent account.** A separate account + exists for the parent (the identity of record), but the managing child holds + or controls the password and shares the profile to siblings. Cleaner than + embedding the parent as a profile inside the child's account, and preserves + the "take over later" path: if the parent becomes able (or, conversely, + passes away), it's the same ownership machinery. +- **Parent modeled as a profile inside the managing child's account.** No + separate login for the parent; the child owns the profile and shares it. + Simplest, but the parent has no independent identity and cannot revoke + themselves. Recommend against this when the parent is or may become an + independent actor. + +**Death/incapacity of the sole-account owner** is the one wrinkle that needs +an explicit decision: see "Permissions and the admin-share question" below. + +### 5c. Permissions and the admin-share question + +The `permissions` list on a recipient share (Open question 3) raises a +sub-decision: **does an `admin` permission let a recipient act as a quasi-owner +without the original owner?** Specifically: re-share the profile, re-key it +(hard revoke), and add/remove other shares. + +This matters for two scenarios: + +- **Graduation in reverse / death of the owner.** If the parent owns the + account and becomes incapacitated, no child can write, re-share, or revoke — + those require the owner's account DEK, which is gone with the password. +- **Operational delegation.** A primary caregiver wants to delegate admin + tasks without sharing their password. + +**Decision: support an `admin` permission tier.** An admin share can re-share, +re-key, and manage other shares on a profile. This keeps the owner-DEK model +intact (the owner share is still special — it can *transfer* ownership, which +admin cannot) while handling the death/incapacity and delegation cases without +key escrow. Hard revocation (re-key) by an admin rotates the profile DEK and +re-wraps to all *still-valid* shares, same as owner-initiated re-key. + +> ❓ **Open sub-item:** when an admin re-keys, do we require a quorum or a +> second admin's consent? For MVP, no — any admin can act alone, matching how +> ownership transfer works today. Revisit if multi-party control becomes a +> requirement. + +### 6. What happens to the current `Share` model + +The existing `backend/src/models/share.rs` is **resource-level ACL** +(`resource_type`, `resource_id`, `permissions`) and predates encryption. It is +incompatible with this design, which shares at the **profile** level via keys, +not at the resource level via permissions. + +Per issue #3, profile-level sharing covers the family/caregiver need; the +"share one document" need is handled separately (physician PDF feature). So: + +- The current `Share` model and `/api/shares` routes are **retained for now** + but treated as a placeholder (issue #3). They will be either removed or + repurposed; this ADR does not depend on them. +- The new recipient-share records live in a **new collection** (provisional + name `profile_shares`) keyed by `(profileId, recipientUserId)`. Storing + wrapped DEKs, not ACLs. + +### 7. Server surface (new endpoints, indicative) + +- `GET /api/users/:id/public-key` → recipient's X25519 public key (plaintext). +- `GET /api/profiles/me/dek` → the account-wrapped profile DEK (owner unlock). +- `POST /api/profiles` → create profile: client generates profile DEK, wraps to + account DEK, sends wrapped form. Returns `profileId`. +- `GET /api/profiles/:id/shares` → list recipient shares for a profile. +- `POST /api/profiles/:id/shares` → create a recipient share (wrapped DEK). +- `DELETE /api/profiles/:id/shares/:shareId` → soft revoke. +- `POST /api/profiles/:id/rekey` → hard revoke / rotate the profile DEK. + +All endpoints store/return opaque blobs or public keys; none expose a DEK or a +private key. + +--- + +## Consequences + +### Positive + +- **Satisfies all four product constraints.** Per-profile keys enable the + divorced-parents case; hard revoke enables the revocation requirement; the + X25519 envelope keeps the server untrusted. +- **Profiles become first-class subjects of care** — owners, shares, pets, + graduation all fall out naturally. +- **Server stays a blind store.** No new backend crypto; still no `aes`/crypto + crates in `Cargo.toml`. +- **Composition with existing recovery.** The account DEK is still recovered + via the existing password/recovery wrapped-DEK flow; profile DEKs are wrapped + to the account DEK, so recovering the account recovers all profiles. + +### Negative / costs + +- **Adds a key tier.** Three levels instead of one. Complexity in the client + crypto module (`crypto/keys.ts` will grow X25519 + wrap-to-recipient logic). +- **Migration.** Today all data is under one account DEK. Splitting into + per-profile DEKs requires reading, decrypting, re-encrypting, and re-uploading + every document. **There is no real user data yet** (per + `zero-knowledge-encryption.md`), so this is cheap **now** and expensive + later — strong argument for doing the re-key before launch. +- **Hard revocation is expensive.** Re-keying a profile is O(profile data + size). Mitigation: soft revoke by default, hard revoke only when the owner + explicitly requires it (e.g. graduation, suspected compromise). +- **Recipient must have an account** (constraint 2). No magic-link sharing in + this design — deferred to the physician-PDF feature. + +### Security notes + +- **Ephemeral keys per share** prevent cross-share correlation and give + forward secrecy within a share's lifetime (re-keying rotates the DEK). +- **ECDH + AES-GCM** is the Web Crypto idiom; X25519 keys are 32 bytes, shares + are small. +- **Public keys are not authenticated by the server.** The owner must trust + that `GET /users/:id/public-key` returns the right key. Since the server is + untrusted, this is an accepted risk: a malicious server could swap the key + and force a re-share, but cannot decrypt. ❓ Decide whether to add a key + fingerprint the recipient verifies out-of-band. + +## Open questions (❓) — resolved 2026-07-18 + +1. **Key hierarchy depth.** ✅ **Decided: 3-tier** (account DEK → profile DEK → + data). Rationale: account DEK is recovered via the existing wrapped-DEK + recovery flow, so password change only re-wraps the account DEK (O(1)), + not every profile DEK. Profile DEKs are re-wrapped only on profile-level + events (share/revoke/rekey), which are rare. + +2. **Public-key trust.** ✅ **Decided: accept key-swap as DoS-only for now.** + A malicious server can swap a recipient's public key and force a failed + share (DoS) but cannot decrypt. **Future enhancement (not blocking):** add + a mechanism for out-of-band pubkey verification. Candidate approaches to + evaluate when we get there: + - **Key fingerprint / safety number.** Display a short hash of a user's + pubkey that two parties can compare out-of-band (cf. Signal safety + numbers). Cheap, requires user action. + - **Pubkey in recovery phrase / account export.** Derive or include the + pubkey fingerprint in something the user already verifies. + - **Web-of-trust / TOFU.** First-seen pubkey is pinned client-side; a change + triggers a user-visible warning. + These are additive — none changes the core envelope design, so they can + ship later without a migration. + +3. **Share record schema.** ✅ **Decided: `profile_shares` collection, schema + as proposed, permissions included.** Fields: + `{profileId, recipientUserId, ephemeralPubKey, wrappedDek, iv, permissions, + expiresAt, active, createdAt}`. `permissions` is a list (e.g. + `["read"]`, `["read","write"]`, `["read","write","admin"]`); the server + enforces write/admin server-side by checking the share record (the owner + always implicitly has admin via the owner share). See §"Permissions and + the admin-share question" below for the semantics of `admin`. + +4. **Migration.** ✅ **Decided: no migration — wipe the database.** There is + no real user data, and the re-key cost is unjustified. Phase A therefore + includes a clean-cutover step rather than a per-document migration. (This + also removes any legacy single-DEK documents that would otherwise need + special-casing.) + +5. **Existing `Share` model fate.** ✅ **Decided: remove entirely.** + `backend/src/models/share.rs`, the `/api/shares` routes, and the + `permissions/check` endpoint are deleted; the new `profile_shares` + collection and `/api/profiles/:id/shares` endpoints replace them. The + future per-resource physician-share feature will be built from scratch on + its own model when prioritized (it's a different access pattern — one-way + document dump, not ongoing profile access). + +## Phasing (proposal) + +- **Phase A — key tiers without sharing.** Introduce per-profile DEKs and the + account X25519 keypair; migrate existing data; no sharing endpoints yet. + Unblocks multi-profile-within-account (the parent/child single-login case). +- **Phase B — profile sharing.** The `POST/GET/DELETE /profiles/:id/shares` + endpoints and soft revoke. +- **Phase C — hard revoke / re-key.** The `/profiles/:id/rekey` endpoint and + owner-driven DEK rotation. +- **Phase D — graduation / ownership transfer.** Owner-share swap UI + endpoint. + +Each phase is independently shippable and independently testable. + +--- + +## References + +- Issue: [#3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3) +- Predecessor ADR: [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md) +- Current client crypto: `web/normogen-web/src/crypto/keys.ts`, `cipher.ts` +- Profile model: `backend/src/models/profile.rs` +- (Incompatible) current share model: `backend/src/models/share.rs` +- Web Crypto API: X25519 via `generateKey({name:"ECDH", namedCurve:"X25519"})`, + `deriveBits` for ECDH, `wrapKey`/`unwrapKey` or manual AES-GCM for the envelope. diff --git a/docs/product/ENCRYPTION_UPDATE_SUMMARY.md b/docs/archive/ENCRYPTION_UPDATE_SUMMARY.md similarity index 85% rename from docs/product/ENCRYPTION_UPDATE_SUMMARY.md rename to docs/archive/ENCRYPTION_UPDATE_SUMMARY.md index 2d745ea..68df3f4 100644 --- a/docs/product/ENCRYPTION_UPDATE_SUMMARY.md +++ b/docs/archive/ENCRYPTION_UPDATE_SUMMARY.md @@ -1,5 +1,13 @@ # Encryption.md Update Summary +> **ARCHIVED 2026-07-18.** This was a changelog note for a 2026-03-09 rewrite of +> `docs/product/encryption.md` that itself became stale (it describes ZK +> encryption as "planned" when it is in fact implemented). Kept here for +> history only. The current, accurate encryption doc is +> [`docs/product/encryption.md`](../product/encryption.md), and the canonical +> design is [`docs/adr/zero-knowledge-encryption.md`](../adr/zero-knowledge-encryption.md). +> See issue #4. + **Date**: 2026-03-09 **File**: docs/product/encryption.md **Update**: Added Rust implementation examples and current status diff --git a/docs/product/PERSONA_AND_FAMILY_MANAGEMENT.md b/docs/product/PERSONA_AND_FAMILY_MANAGEMENT.md index 5a99c39..e1131f8 100644 --- a/docs/product/PERSONA_AND_FAMILY_MANAGEMENT.md +++ b/docs/product/PERSONA_AND_FAMILY_MANAGEMENT.md @@ -1,10 +1,16 @@ # Persona and Family Management - Product Definition -**Document Version**: 1.0 -**Date**: 2026-03-09 -**Status**: Draft - For Refinement +**Document Version**: 1.1 +**Date**: 2026-07-18 (reconciled with code; original draft 2026-03-09) +**Status**: Vision doc — implementation status section corrected to match code **Purpose**: Consolidate all current information about persona and family management features for further product refinement +> **How to read this doc.** Sections marked **Vision** describe the target +> product and are still valid for discussion. Sections marked **Today (code)** +> describe what is actually implemented and have been verified against the +> codebase as of 2026-07-18. Where the two diverge, the gap is called out and +> linked to the relevant Forgejo issue. + --- ## 📋 Table of Contents @@ -35,6 +41,28 @@ Normogen supports **multi-person health data management** through a persona and --- +## Current reality (as of 2026-07-18) + +This section corrects the earlier "Current Implementation Status" section below, +which described an earlier state. Verified against the code: + +| Capability | Status | Notes | +|---|---|---| +| Single profile per user account | ✅ Done | `GET/PUT /api/profiles/me`; profile is auto-created as `profile_{user_id}` at registration. Display name is client-encrypted. | +| Multi-profile (child/dependent) within one account | ❌ Not built | `ProfileRepository::find_by_user_id` returns a single profile; no create/list/switch endpoints. `profileId` can be tagged onto medications but there is no backing entity beyond the owner's. | +| Family groups | ❌ Not built | `Family` model exists but has **no handler and no routes**. JWT carries `family_id` but nothing populates or checks it. | +| Cross-user sharing (spouse, caregiver) | ❌ **Blocked by design** | `/api/shares` routes exist but cannot grant decryption access — all data is encrypted under the owner's DEK and there is no key-distribution mechanism. See **[issue #3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3)**. | +| Caregiver roles / permissions | ❌ Not built | `permissions` field exists on Profile and in JWT claims but is not enforced. | +| Zero-knowledge encryption of health data | ✅ Done | Client-side Web Crypto, wrapped-DEK recovery. See [`encryption.md`](encryption.md). | + +**Implication for the family/caregiver personas below:** the single biggest +open question is architectural — *how does sharing work under zero-knowledge?* +That must be decided before family/caregiver features can be implemented. Until +then, the realistic near-term scope is **multi-profile within a single account** +(one login managing several people's data, no cross-account sharing). + +--- + ## User Personas ### Primary: Privacy-Conscious Individual @@ -167,7 +195,12 @@ User Account: jane.doe@example.com (Jane) --- -## Current Implementation Status +## Current Implementation Status (superseded — see "Current reality" above) + +> The detail below was written 2026-03-09 against an earlier state and +> overstates what existed then. It is retained for history. For the accurate +> current picture, read the **Current reality** section near the top of this +> document, and [`encryption.md`](encryption.md) for encryption. ### ✅ Implemented (Backend) @@ -595,16 +628,23 @@ if profile.role == "child" || profile.age < 18 { ### Encryption -**Zero-Knowledge Encryption**: -- Profile names are encrypted (AES-256-GCM) -- Family names are encrypted -- Only users with correct password can decrypt -- Server cannot see profile names +> **Correction (2026-07-18):** the bullets below described a planned +> server-side scheme that was never built. The shipped system is +> **client-side** zero-knowledge via the browser's Web Crypto API with a +> wrapped-DEK model. The server has no crypto code at all — it is a blind +> store. Profile/medication/appointment data is encrypted in the browser +> before upload and the server cannot read any of it. Full details: +> [`encryption.md`](encryption.md) and +> [`adr/zero-knowledge-encryption.md`](../adr/zero-knowledge-encryption.md). -**Encrypted Fields**: -- `profile.name` - Person's name -- `family.name` - Family group name -- Medication data (when implemented) +**What's actually encrypted (client-side, today):** +- Medication data blob (name, dosage, frequency, route, notes, …) +- Appointment data blob (title, provider, date/time, …) +- Profile display name + +**What stays plaintext (queryable by the server):** +- IDs (`profileId`, `medicationId`, `userId`, `appointmentId`) +- Medication `active` flag, appointment `status`, `doseSchedule`, timestamps --- diff --git a/docs/product/encryption.md b/docs/product/encryption.md index 55302f6..a7e30ab 100644 --- a/docs/product/encryption.md +++ b/docs/product/encryption.md @@ -1,906 +1,257 @@ -# Zero-Knowledge Encryption Implementation Guide +# Zero-Knowledge Encryption -## Table of Contents -1. [Proton-Style Encryption for MongoDB](#proton-style-encryption-for-mongodb) -2. [Shareable Links with Embedded Passwords](#shareable-links-with-embedded-passwords) -3. [Security Best Practices](#security-best-practices) -4. [Advanced Features](#advanced-features) -5. [Rust Implementation Examples](#rust-implementation-examples) 🆕 +**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/`. --- -## 🚨 Implementation Status +## 1. The core property -**Last Updated**: 2026-03-09 +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: -### Currently Implemented in Normogen ✅ -- ✅ JWT authentication (15min access tokens, 30day refresh tokens) -- ✅ PBKDF2 password hashing (100,000 iterations) -- ✅ Password recovery with zero-knowledge phrases -- ✅ Rate limiting (tower-governor) -- ✅ Account lockout policies -- ✅ Security audit logging -- ✅ Session management +- 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. -### Not Yet Implemented 📋 -- 📋 End-to-end encryption for health data -- 📋 Client-side encryption before storage -- 📋 Zero-knowledge encryption implementation -- 📋 Shareable links with embedded passwords - -> **Note**: The sections below provide a comprehensive guide for implementing zero-knowledge encryption. These are design documents for future implementation. +This is the feature that differentiates Normogen from commercial health +platforms: even the operator of the server cannot read your data. --- -## Proton-Style Encryption for MongoDB +## 2. Key model (wrapped-DEK) -### Architecture Overview +Two layers of keys: ``` -Application Layer (Client Side) -├── Encryption/Decryption happens HERE -├── Queries constructed with encrypted searchable fields -└── Data never leaves application unencrypted - -MongoDB (Server Side) -└── Stores only encrypted data + ┌─── 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) ``` -### Implementation Approaches +- **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. -#### 1. Application-Level Encryption (Recommended) +### Key derivation parameters -Encrypt sensitive fields before they reach MongoDB. +| 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`. --- -## Rust Implementation Examples 🆕 +## 3. Lifecycle -### Current Security Implementation +### Registration -Normogen currently implements the following security features in Rust: +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. -#### 1. JWT Authentication Service +### Login (unlock) -**File**: `backend/src/auth/mod.rs` +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`). -```rust -use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; -use serde::{Deserialize, Serialize}; -use chrono::{Duration, Utc}; +### Password change -#[derive(Debug, Serialize, Deserialize)] -pub struct Claims { - pub sub: String, // User ID - pub exp: usize, // Expiration time - pub iat: usize, // Issued at - pub token_type: String, // "access" or "refresh" -} +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. -pub struct AuthService { - jwt_secret: String, -} +### Password recovery (via recovery phrase) -impl AuthService { - pub fn new(jwt_secret: String) -> Self { - Self { jwt_secret } - } +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). - /// Generate access token (15 minute expiry) - pub fn generate_access_token(&self, user_id: &str) -> Result { - let expiration = Utc::now() - .checked_add_signed(Duration::minutes(15)) - .expect("valid timestamp") - .timestamp() as usize; - - let claims = Claims { - sub: user_id.to_owned(), - exp: expiration, - iat: Utc::now().timestamp() as usize, - token_type: "access".to_string(), - }; - - encode( - &Header::default(), - &claims, - &EncodingKey::from_secret(self.jwt_secret.as_ref()), - ) - .map_err(|e| Error::TokenCreation(e.to_string())) - } - - /// Generate refresh token (30 day expiry) - pub fn generate_refresh_token(&self, user_id: &str) -> Result { - let expiration = Utc::now() - .checked_add_signed(Duration::days(30)) - .expect("valid timestamp") - .timestamp() as usize; - - let claims = Claims { - sub: user_id.to_owned(), - exp: expiration, - iat: Utc::now().timestamp() as usize, - token_type: "refresh".to_string(), - }; - - encode( - &Header::default(), - &claims, - &EncodingKey::from_secret(self.jwt_secret.as_ref()), - ) - .map_err(|e| Error::TokenCreation(e.to_string())) - } - - /// Validate JWT token - pub fn validate_token(&self, token: &str) -> Result { - decode::( - token, - &DecodingKey::from_secret(self.jwt_secret.as_ref()), - &Validation::default(), - ) - .map(|data| data.claims) - .map_err(|e| Error::TokenValidation(e.to_string())) - } -} -``` - -#### 2. Password Hashing with PBKDF2 - -**File**: `backend/src/auth/mod.rs` - -```rust -use pbkdf2::{ - password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, SaltString}, - Pbkdf2, - pbkdf2::Params, -}; - -pub struct PasswordService; - -impl PasswordService { - /// Hash password using PBKDF2 (100,000 iterations) - pub fn hash_password(password: &str) -> Result { - let params = Params::new(100_000, 0, 32, 32).expect("valid params"); - let salt = SaltString::generate(&mut OsRng); - - let password_hash = Pbkdf2 - .hash_password(password.as_bytes(), &salt) - .map_err(|e| Error::Hashing(e.to_string()))?; - - Ok(password_hash.to_string()) - } - - /// Verify password against hash - pub fn verify_password(password: &str, hash: &str) -> Result { - let parsed_hash = PasswordHash::new(hash) - .map_err(|e| Error::HashValidation(e.to_string()))?; - - Pbkdf2 - .verify_password(password.as_bytes(), &parsed_hash) - .map(|_| true) - .map_err(|e| match e { - pbkdf2::password_hash::Error::Password => Ok(false), - _ => Err(Error::HashValidation(e.to_string())), - })? - } - - /// Generate zero-knowledge recovery phrase - pub fn generate_recovery_phrase() -> String { - use rand::Rng; - const PHRASE_LENGTH: usize = 12; - const WORDS: &[&str] = &[ - "alpha", "bravo", "charlie", "delta", "echo", "foxtrot", - "golf", "hotel", "india", "juliet", "kilo", "lima", - "mike", "november", "oscar", "papa", "quebec", "romeo", - "sierra", "tango", "uniform", "victor", "whiskey", "xray", - "yankee", "zulu", - ]; - - let mut rng = rand::thread_rng(); - let phrase: Vec = (0..PHRASE_LENGTH) - .map(|_| WORDS[rng.gen_range(0..WORDS.len())].to_string()) - .collect(); - - phrase.join("-") - } -} -``` - -#### 3. Rate Limiting Middleware - -**File**: `backend/src/middleware/mod.rs` - -```rust -use axum::{ - extract::Request, - http::StatusCode, - middleware::Next, - response::Response, -}; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::RwLock; -use tower_governor::{ - governor::GovernorConfigBuilder, - key_bearer::BearerKeyExtractor, -}; - -#[derive(Clone)] -pub struct RateLimiter { - config: Arc>, -} - -impl RateLimiter { - pub fn new() -> Self { - let config = GovernorConfigBuilder::default() - .per_second(15) // 15 requests per second - .burst_size(30) // Allow bursts of 30 requests - .finish() - .unwrap(); - - Self { - config: Arc::new(config), - } - } -} - -/// Rate limiting middleware for Axum -pub async fn rate_limit_middleware( - req: Request, - next: Next, -) -> Result { - // Extract user ID or IP address for rate limiting - let key = extract_key(&req)?; - - // Check rate limit - // Implementation depends on tower-governor setup - - Ok(next.run(req).await) -} - -fn extract_key(req: &Request) -> Result { - // Extract from JWT token or IP address - // For now, use IP address - Ok("client_ip".to_string()) -} -``` - -#### 4. Account Lockout Service - -**File**: `backend/src/security/account_lockout.rs` - -```rust -use std::collections::HashMap; -use std::sync::Arc; -use tokio::sync::RwLock; -use std::time::{Duration, Instant}; - -#[derive(Clone)] -pub struct FailedLoginAttempt { - pub count: u32, - pub first_attempt: Instant, - pub last_attempt: Instant, -} - -pub struct AccountLockoutService { - attempts: Arc>>, - max_attempts: u32, - base_lockout_duration: Duration, - max_lockout_duration: Duration, -} - -impl AccountLockoutService { - pub fn new() -> Self { - Self { - attempts: Arc::new(RwLock::new(HashMap::new())), - max_attempts: 5, - base_lockout_duration: Duration::from_secs(900), // 15 minutes - max_lockout_duration: Duration::from_secs(86400), // 24 hours - } - } - - /// Record failed login attempt - pub async fn record_failed_attempt(&self, user_id: &str) -> Duration { - let mut attempts = self.attempts.write().await; - let now = Instant::now(); - - let attempt = attempts.entry(user_id.to_string()).or_insert_with(|| { - FailedLoginAttempt { - count: 0, - first_attempt: now, - last_attempt: now, - } - }); - - attempt.count += 1; - attempt.last_attempt = now; - - // Calculate lockout duration (exponential backoff) - if attempt.count >= self.max_attempts { - let lockout_duration = self.calculate_lockout_duration(attempt.count); - return lockout_duration; - } - - Duration::ZERO - } - - /// Clear failed login attempts on successful login - pub async fn clear_attempts(&self, user_id: &str) { - let mut attempts = self.attempts.write().await; - attempts.remove(user_id); - } - - /// Check if account is locked - pub async fn is_locked(&self, user_id: &str) -> bool { - let attempts = self.attempts.read().await; - if let Some(attempt) = attempts.get(user_id) { - if attempt.count >= self.max_attempts { - let lockout_duration = self.calculate_lockout_duration(attempt.count); - return attempt.last_attempt.add_duration(lockout_duration) > Instant::now(); - } - } - false - } - - /// Calculate lockout duration with exponential backoff - fn calculate_lockout_duration(&self, attempt_count: u32) -> Duration { - let base_secs = self.base_lockout_duration.as_secs() as u32; - let exponent = attempt_count.saturating_sub(self.max_attempts); - - // Exponential backoff: 15min, 30min, 1hr, 2hr, 4hr, max 24hr - let duration_secs = base_secs * 2_u32.pow(exponent.min(4)); - - let duration = Duration::from_secs(duration_secs as u64); - duration.min(self.max_lockout_duration) - } -} -``` - -#### 5. Security Audit Logger - -**File**: `backend/src/security/audit_logger.rs` - -```rust -use chrono::Utc; -use serde::{Deserialize, Serialize}; -use mongodb::{ - bson::{doc, Bson}, - Collection, Database, -}; - -#[derive(Debug, Serialize, Deserialize)] -pub struct AuditLog { - #[serde(rename = "_id")] - pub id: String, - pub user_id: String, - pub action: String, - pub resource: String, - pub details: serde_json::Value, - pub ip_address: String, - pub user_agent: String, - pub timestamp: i64, - pub success: bool, -} - -pub struct AuditLogger { - collection: Collection, -} - -impl AuditLogger { - pub fn new(db: &Database) -> Self { - Self { - collection: db.collection("audit_logs"), - } - } - - /// Log security event - pub async fn log_event( - &self, - user_id: &str, - action: &str, - resource: &str, - details: serde_json::Value, - ip_address: &str, - user_agent: &str, - success: bool, - ) -> Result<(), Error> { - let log_entry = AuditLog { - id: uuid::Uuid::new_v4().to_string(), - user_id: user_id.to_string(), - action: action.to_string(), - resource: resource.to_string(), - details, - ip_address: ip_address.to_string(), - user_agent: user_agent.to_string(), - timestamp: Utc::now().timestamp_millis(), - success, - }; - - self.collection - .insert_one(log_entry) - .await - .map_err(|e| Error::Database(e.to_string()))?; - - Ok(()) - } - - /// Log authentication attempt - pub async fn log_auth_attempt( - &self, - user_id: &str, - method: &str, // "login", "register", "logout" - success: bool, - ip_address: &str, - user_agent: &str, - ) -> Result<(), Error> { - let details = serde_json::json!({ - "method": method, - "success": success, - }); - - self.log_event( - user_id, - "authentication", - "auth", - details, - ip_address, - user_agent, - success, - ) - .await - } - - /// Log authorization attempt - pub async fn log_permission_check( - &self, - user_id: &str, - resource: &str, - permission: &str, - success: bool, - ip_address: &str, - ) -> Result<(), Error> { - let details = serde_json::json!({ - "permission": permission, - "success": success, - }); - - self.log_event( - user_id, - "authorization", - resource, - details, - ip_address, - "system", - success, - ) - .await - } -} -``` +This is the hard problem in zero-knowledge systems and it is solved here +without the server ever touching an unwrapped key. --- -## Future Zero-Knowledge Encryption Design +## 4. What is encrypted vs. plaintext -### Proposed Implementation for Health Data +The split is deliberate: enough stays plaintext for the server to query and +aggregate (filtering, adherence math); everything sensitive is ciphertext. -The following sections describe how to implement zero-knowledge encryption for sensitive health data. This is currently **not implemented** in Normogen. +### Encrypted (client-side, under the DEK) -#### 1. Encryption Service Design +- 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 -```rust -use aes_gcm::{ - aead::{Aead, AeadCore, KeyInit, OsRng}, - Aes256Gcm, Nonce, -}; -use rand::RngCore; +### Plaintext (server-visible, queryable) -pub struct EncryptionService { - cipher: Aes256Gcm, -} +- 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 -impl EncryptionService { - /// Create new encryption service with key - pub fn new(key: &[u8; 32]) -> Self { - let cipher = Aes256Gcm::new(key.into()); - Self { cipher } - } +### Wire shape - /// Encrypt data - pub fn encrypt(&self, plaintext: &[u8]) -> Result, Error> { - let nonce = Aes256Gcm::generate_nonce(&mut OsRng); - let ciphertext = self.cipher.encrypt(&nonce, plaintext) - .map_err(|e| Error::Encryption(e.to_string()))?; - - // Return nonce + ciphertext - let mut result = nonce.to_vec(); - result.extend_from_slice(&ciphertext); - Ok(result) - } - - /// Decrypt data - pub fn decrypt(&self, data: &[u8]) -> Result, Error> { - if data.len() < 12 { - return Err(Error::Decryption("Invalid data length".to_string())); - } - - let (nonce, ciphertext) = data.split_at(12); - let nonce = Nonce::from_slice(nonce); - - self.cipher.decrypt(nonce, ciphertext) - .map_err(|e| Error::Decryption(e.to_string())) - } - - /// Derive key from password using PBKDF2 - pub fn derive_key_from_password( - password: &str, - salt: &[u8; 32], - ) -> [u8; 32] { - use pbkdf2::pbkdf2_hmac; - use sha2::Sha256; - - let mut key = [0u8; 32]; - pbkdf2_hmac::( - password.as_bytes(), - salt, - 100_000, // iterations - &mut key, - ); - key - } -} +``` +EncryptedField { data: base64, iv: base64, auth_tag: base64 } ``` -#### 2. Encrypted Health Data Model +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. -```rust -use serde::{Deserialize, Serialize}; -use mongodb::bson::Uuid; - -#[derive(Debug, Serialize, Deserialize)] -pub struct EncryptedHealthData { - pub id: Uuid, - pub user_id: Uuid, - pub data_type: String, // "medication", "lab_result", "stat" - - // Encrypted fields - pub encrypted_data: Vec, - pub nonce: Vec, // 12 bytes for AES-256-GCM - - // Searchable (deterministically encrypted) - pub encrypted_name_searchable: Vec, - - pub created_at: i64, - pub updated_at: i64, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct HealthData { - pub id: Uuid, - pub user_id: Uuid, - pub data_type: String, - pub name: String, - pub value: serde_json::Value, - pub created_at: i64, - pub updated_at: i64, -} -``` - -#### 3. Deterministic Encryption for Searchable Fields - -```rust -use aes_gcm::{ - aead::{Aead, KeyInit}, - Aes256Gcm, Nonce, -}; - -pub struct DeterministicEncryption { - cipher: Aes256Gcm, -} - -impl DeterministicEncryption { - /// Create deterministic encryption from key - /// Note: Uses same nonce for same input (less secure, but searchable) - pub fn new(key: &[u8; 32]) -> Self { - let cipher = Aes256Gcm::new(key.into()); - Self { cipher } - } - - /// Generate nonce from input data (deterministic) - fn generate_nonce_from_data(data: &[u8]) -> [u8; 12] { - use sha2::{Sha256, Digest}; - let hash = Sha256::digest(data); - let mut nonce = [0u8; 12]; - nonce.copy_from_slice(&hash[..12]); - nonce - } - - /// Encrypt deterministically (same input = same output) - pub fn encrypt(&self, data: &[u8]) -> Result, Error> { - let nonce_bytes = Self::generate_nonce_from_data(data); - let nonce = Nonce::from_slice(&nonce_bytes); - - self.cipher.encrypt(nonce, data) - .map_err(|e| Error::Encryption(e.to_string())) - } -} -``` +> Reference: `web/normogen-web/src/crypto/cipher.ts`, `backend/src/models/medication.rs` (`EncryptedFieldWire`). --- -## Key Management Strategy +## 5. In-memory key lifecycle (the ZK trade-off) -### Environment Variables +The DEK lives only in browser memory for the authenticated session. It is +**not persisted** to `localStorage`, `sessionStorage`, or a cookie. -```bash -# backend/.env -JWT_SECRET=your-256-bit-secret-key-here -MASTER_ENCRYPTION_KEY=your-256-bit-encryption-key-here -SHARE_LINK_MASTER_KEY=your-256-bit-share-key-here -``` +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. -### Key Derivation - -```rust -use sha2::{Sha256, Digest}; - -pub struct KeyManager { - master_key: [u8; 32], -} - -impl KeyManager { - pub fn new(master_key: [u8; 32]) -> Self { - Self { master_key } - } - - /// Derive unique key per user - pub fn derive_user_key(&self, user_id: &str) -> [u8; 32] { - let mut hasher = Sha256::new(); - hasher.update(format!("{}:{}", hex::encode(self.master_key), user_id)); - hasher.finalize().into() - } - - /// Derive key for specific document - pub fn derive_document_key(&self, user_id: &str, doc_id: &str) -> [u8; 32] { - let mut hasher = Sha256::new(); - hasher.update(format!( - "{}:{}:{}", - hex::encode(self.master_key), - user_id, - doc_id - )); - hasher.finalize().into() - } -} -``` +On logout / tab close, the key is cleared via `clearEncKey()`. --- -## Shareable Links with Embedded Passwords +## 6. What is NOT yet solved (see issues) -### Architecture Overview +### Multi-person / family / caregiver sharing — unsolved -``` -1. User has encrypted data in MongoDB -2. Creates a public share with a password -3. Generates a shareable link with encrypted password -4. External user clicks link → password extracted → data decrypted -``` +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. -### Implementation Design +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)**. -```rust -use rand::RngCore; +### Multi-profile within one account — partial -#[derive(Debug, Serialize, Deserialize)] -pub struct ShareableLink { - pub share_id: String, - pub encrypted_password: String, // Embedded in URL - pub expires_at: Option, - pub max_access_count: Option, -} +`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. -pub struct ShareService { - encryption_service: EncryptionService, -} +### Key rotation / re-encryption -impl ShareService { - /// Create shareable link - pub fn create_shareable_link( - &self, - data: &[u8], - expires_in_hours: Option, - max_access: Option, - ) -> Result { - // Generate random password - let mut password = [0u8; 32]; - OsRng.fill_bytes(&mut password); - - // Encrypt data with password - let encrypted_data = self.encryption_service.encrypt(&password, data)?; - - // Generate share ID - let share_id = generate_share_id(); - - // Encrypt password for URL embedding - let encrypted_password = self.encrypt_password_for_url(&password)?; - - Ok(ShareableLink { - share_id, - encrypted_password, - expires_at: expires_in_hours.map(|h| { - Utc::now().timestamp() + (h * 3600) as i64 - }), - max_access_count: max_access, - }) - } - - /// Encrypt password for embedding in URL - fn encrypt_password_for_url(&self, password: &[u8; 32]) -> Result { - // Use master key to encrypt password - let encrypted = self.encryption_service.encrypt( - &MASTER_ENCRYPTION_KEY, - password, - )?; - Ok(base64::encode(encrypted)) - } -} - -fn generate_share_id() -> String { - use rand::Rng; - const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - let mut rng = rand::thread_rng(); - - (0..16) - .map(|_| { - let idx = rng.gen_range(0..CHARSET.len()); - CHARSET[idx] as char - }) - .collect() -} -``` +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. --- -## Password Recovery in Zero-Knowledge Systems +## 7. Backend security (complementary, server-side) -### Recovery with Zero-Knowledge Phrases +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). -Currently implemented in Normogen: +| 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 | -```rust -// Already implemented in backend/src/auth/mod.rs -impl AuthService { - pub fn generate_recovery_phrase() -> String { - // Returns phrase like "alpha-bravo-charlie-..." - } - - pub fn verify_recovery_phrase( - &self, - user_id: &str, - phrase: &str, - ) -> Result { - // Verify phrase and allow password reset - } -} -``` +> Reference: `backend/src/auth/`, `backend/src/security/`, `backend/src/models/audit_log.rs`. --- -## Security Best Practices +## 8. Threat model summary -### Current Implementation ✅ - -1. **Password Security** - - ✅ PBKDF2 with 100,000 iterations - - ✅ Random salt per password - - ✅ Passwords never logged - -2. **JWT Security** - - ✅ Short-lived access tokens (15 minutes) - - ✅ Long-lived refresh tokens (30 days) - - ✅ Token rotation on refresh - -3. **Rate Limiting** - - ✅ 15 requests per second - - ✅ Burst allowance of 30 requests - -4. **Account Lockout** - - ✅ 5 failed attempts trigger lockout - - ✅ Exponential backoff (15min → 24hr max) - -5. **Audit Logging** - - ✅ All security events logged - - ✅ IP address and user agent tracked - -### Recommendations for Future Enhancement - -1. **End-to-End Encryption** - - Implement client-side encryption - - Zero-knowledge encryption for health data - - Deterministic encryption for searchable fields - -2. **Key Rotation** - - Implement periodic key rotation - - Support for encryption key updates - -3. **Hardware Security Modules (HSM)** - - Consider HSM for production deployments - - Secure key storage and management - -4. **Compliance** - - HIPAA compliance measures - - GDPR compliance features - - Data localization options +| 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 | --- -## Comparison: Current vs Proposed +## References -### Current Implementation ✅ - -| Feature | Status | Notes | -|---------|--------|-------| -| JWT Authentication | ✅ Implemented | 15min access, 30day refresh | -| Password Hashing | ✅ Implemented | PBKDF2, 100K iterations | -| Rate Limiting | ✅ Implemented | 15 req/s, burst 30 | -| Account Lockout | ✅ Implemented | 5 attempts, 15min-24hr | -| Audit Logging | ✅ Implemented | All security events | -| Session Management | ✅ Implemented | List, revoke sessions | -| Zero-Knowledge Encryption | 📋 Planned | Not yet implemented | - -### Proposed Implementation 📋 - -| Feature | Priority | Complexity | -|---------|----------|------------| -| Client-side Encryption | High | High | -| End-to-End Encryption | High | High | -| Deterministic Encryption | Medium | Medium | -| Shareable Links | Medium | Medium | -| Key Rotation | High | Medium | -| HSM Integration | Low | High | - ---- - -## Dependencies - -### Currently Used ✅ -```toml -# backend/Cargo.toml -jsonwebtoken = "9.3.1" # JWT authentication -pbkdf2 = "0.12" # Password hashing -rand = "0.8" # Random generation -sha2 = "0.10" # SHA-256 hashing -tower-governor = "0.4" # Rate limiting -chrono = "0.4" # Time handling -``` - -### To Add for Encryption 📋 -```toml -# Proposed additions -aes-gcm = "0.10" # AES-256-GCM encryption -base64 = "0.21" # Base64 encoding -uuid = "1.0" # UUID generation -``` - ---- - -## Summary - -This document provides: - -✅ **Current implementation**: JWT, PBKDF2, rate limiting, audit logging -✅ **Rust code examples**: Actual implementation from Normogen -✅ **Future design**: Zero-knowledge encryption architecture -✅ **Best practices**: Security recommendations and compliance - -### Implementation Status - -- **Security Features**: ✅ 85% complete -- **Encryption Features**: 📋 Planned for future phases -- **Documentation**: ✅ Complete - ---- - -**Last Updated**: 2026-03-09 -**Implementation Status**: Security features implemented, zero-knowledge encryption planned -**Next Review**: After Phase 2.8 completion +- 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`