diff --git a/docs/adr/README.md b/docs/adr/README.md index 1759ef1..b8b4e9f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -11,15 +11,14 @@ 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, `token_version` revocation, refresh rotation — *reconciled with code 2026-07-18* | +| [jwt-authentication-decision.md](./jwt-authentication-decision.md) | JWT access/refresh tokens, recovery phrases | | [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, 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) | +| [zero-knowledge-encryption.md](./zero-knowledge-encryption.md) | Client-side zero-knowledge encryption (AES-256-GCM, double-PBKDF2, Phase 1) | > **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 b8d2190..bf3f59b 100644 --- a/docs/adr/jwt-authentication-decision.md +++ b/docs/adr/jwt-authentication-decision.md @@ -1,185 +1,174 @@ -# ADR: JWT Authentication +# JWT Authentication Decision Summary -**Status**: Implemented (current code is the source of truth) -**Date**: 2026-02-14 (original decision), 2026-07-18 (reconciled with code) +**Date**: 2026-02-14 +**Decision**: **JWT with Refresh Tokens + Recovery Phrases** -> 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**. +--- -## Context +## Authentication Strategy -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. +### Primary: JWT (JSON Web Tokens) -## Decision +**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 -### Token model +### Token Types -Two JWTs, both HS256-signed with a shared secret from config: +**Access Token** (15 minutes) +- Used for API requests +- Short-lived for security +- Contains: user_id, email, family_id, permissions -| 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 | +**Refresh Token** (30 days) +- Used to get new access tokens +- Long-lived for convenience +- Stored in MongoDB for revocation +- Rotated on every refresh -> Reference: `backend/src/auth/jwt.rs` (`Claims`, `RefreshClaims`, `JwtService`). +--- -The lifetimes are **config-driven**, not hardcoded — the original ADR's "15 min -/ 30 days" are the defaults, not constants. +## Token Revocation Strategies -### Claims (actual, as implemented) +### 1. Refresh Token Blacklist (Recommended) ⭐ +- Store refresh tokens in MongoDB +- Mark as revoked on logout +- Check on every refresh -The earlier ADR listed richer claims (`family_id`, `permissions`, `token_type`). -**These are not in the shipped access token.** The real `Claims` struct is: +### 2. Token Versioning +- Include version in JWT claims +- Increment on password change +- Invalidate all tokens when version changes -```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, -} +### 3. Access Token Blacklist (Optional) +- Store revoked access tokens in Redis +- For immediate revocation +- Auto-expires with TTL -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, +--- + +## 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" + ] } ``` -Note what is **absent** and why: +### Permission Middleware -- **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. +- Check permissions on protected routes +- Return 403 Forbidden if insufficient permissions +- Works with JWT claims -### Refresh tokens: persistence, rotation, reuse detection +--- -- 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). +## Technology Stack -### Token revocation: versioning + session management +### Backend (Axum) +- jsonwebtoken 9.x (JWT crate) +- bcrypt 0.15 (password hashing) +- mongodb 3.0 (refresh token storage) +- redis (optional, for access token blacklist) -Two mechanisms, both implemented: +### Client (React Native + React) +- AsyncStorage (token storage) +- axios (API client with JWT interceptor) +- PBKDF2 (password derivation) +- AES-256-GCM (data encryption) -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. +--- -### Password handling +## Implementation Timeline -- **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. +- **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) -### Password recovery +**Total**: 3-4 weeks -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. +--- -### What protects what +## Next Steps -Auth + the ciphertext store are defended server-side; plaintext health data is -never on the server to protect: +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) -| 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 -- 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) +- [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) diff --git a/docs/adr/multi-person-sharing.md b/docs/adr/multi-person-sharing.md deleted file mode 100644 index e2e1825..0000000 --- a/docs/adr/multi-person-sharing.md +++ /dev/null @@ -1,336 +0,0 @@ -# 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/archive/ENCRYPTION_UPDATE_SUMMARY.md b/docs/product/ENCRYPTION_UPDATE_SUMMARY.md similarity index 85% rename from docs/archive/ENCRYPTION_UPDATE_SUMMARY.md rename to docs/product/ENCRYPTION_UPDATE_SUMMARY.md index 68df3f4..2d745ea 100644 --- a/docs/archive/ENCRYPTION_UPDATE_SUMMARY.md +++ b/docs/product/ENCRYPTION_UPDATE_SUMMARY.md @@ -1,13 +1,5 @@ # 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 e1131f8..5a99c39 100644 --- a/docs/product/PERSONA_AND_FAMILY_MANAGEMENT.md +++ b/docs/product/PERSONA_AND_FAMILY_MANAGEMENT.md @@ -1,16 +1,10 @@ # Persona and Family Management - Product Definition -**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 +**Document Version**: 1.0 +**Date**: 2026-03-09 +**Status**: Draft - For Refinement **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 @@ -41,28 +35,6 @@ 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 @@ -195,12 +167,7 @@ User Account: jane.doe@example.com (Jane) --- -## 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. +## Current Implementation Status ### ✅ Implemented (Backend) @@ -628,23 +595,16 @@ if profile.role == "child" || profile.age < 18 { ### Encryption -> **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). +**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 -**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 +**Encrypted Fields**: +- `profile.name` - Person's name +- `family.name` - Family group name +- Medication data (when implemented) --- diff --git a/docs/product/encryption.md b/docs/product/encryption.md index a7e30ab..55302f6 100644 --- a/docs/product/encryption.md +++ b/docs/product/encryption.md @@ -1,257 +1,906 @@ -# Zero-Knowledge Encryption +# Zero-Knowledge Encryption Implementation Guide -**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/`. +## 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) 🆕 --- -## 1. The core property +## 🚨 Implementation Status -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: +**Last Updated**: 2026-03-09 -- 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. +### 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 -This is the feature that differentiates Normogen from commercial health -platforms: even the operator of the server cannot read your data. +### 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. --- -## 2. Key model (wrapped-DEK) +## Proton-Style Encryption for MongoDB -Two layers of keys: +### Architecture Overview ``` - ┌─── 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) +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 ``` -- **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. +### Implementation Approaches -### Key derivation parameters +#### 1. Application-Level Encryption (Recommended) -| 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`. +Encrypt sensitive fields before they reach MongoDB. --- -## 3. Lifecycle +## Rust Implementation Examples 🆕 -### Registration +### Current Security Implementation -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. +Normogen currently implements the following security features in Rust: -### Login (unlock) +#### 1. JWT Authentication Service -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`). +**File**: `backend/src/auth/mod.rs` -### Password change +```rust +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use chrono::{Duration, Utc}; -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. +#[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" +} -### Password recovery (via recovery phrase) +pub struct AuthService { + jwt_secret: String, +} -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). +impl AuthService { + pub fn new(jwt_secret: String) -> Self { + Self { jwt_secret } + } -This is the hard problem in zero-knowledge systems and it is solved here -without the server ever touching an unwrapped key. + /// 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(), + }; -## 4. What is encrypted vs. plaintext + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(self.jwt_secret.as_ref()), + ) + .map_err(|e| Error::TokenCreation(e.to_string())) + } -The split is deliberate: enough stays plaintext for the server to query and -aggregate (filtering, adherence math); everything sensitive is ciphertext. + /// 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; -### Encrypted (client-side, under the DEK) + let claims = Claims { + sub: user_id.to_owned(), + exp: expiration, + iat: Utc::now().timestamp() as usize, + token_type: "refresh".to_string(), + }; -- 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 + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(self.jwt_secret.as_ref()), + ) + .map_err(|e| Error::TokenCreation(e.to_string())) + } -### 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 } + /// 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())) + } +} ``` -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. +#### 2. Password Hashing with PBKDF2 -> Reference: `web/normogen-web/src/crypto/cipher.ts`, `backend/src/models/medication.rs` (`EncryptedFieldWire`). +**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 + } +} +``` --- -## 5. In-memory key lifecycle (the ZK trade-off) +## Future Zero-Knowledge Encryption Design -The DEK lives only in browser memory for the authenticated session. It is -**not persisted** to `localStorage`, `sessionStorage`, or a cookie. +### Proposed Implementation for Health Data -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. +The following sections describe how to implement zero-knowledge encryption for sensitive health data. This is currently **not implemented** in Normogen. -On logout / tab close, the key is cleared via `clearEncKey()`. +#### 1. Encryption Service Design + +```rust +use aes_gcm::{ + aead::{Aead, AeadCore, KeyInit, OsRng}, + Aes256Gcm, Nonce, +}; +use rand::RngCore; + +pub struct EncryptionService { + cipher: Aes256Gcm, +} + +impl EncryptionService { + /// Create new encryption service with key + pub fn new(key: &[u8; 32]) -> Self { + let cipher = Aes256Gcm::new(key.into()); + Self { cipher } + } + + /// 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 + } +} +``` + +#### 2. Encrypted Health Data Model + +```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())) + } +} +``` --- -## 6. What is NOT yet solved (see issues) +## Key Management Strategy -### Multi-person / family / caregiver sharing — unsolved +### Environment Variables -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. +```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 +``` -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)**. +### Key Derivation -### Multi-profile within one account — partial +```rust +use sha2::{Sha256, Digest}; -`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 KeyManager { + master_key: [u8; 32], +} -### Key rotation / re-encryption +impl KeyManager { + pub fn new(master_key: [u8; 32]) -> Self { + Self { master_key } + } -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. + /// 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() + } +} +``` --- -## 7. Backend security (complementary, server-side) +## Shareable Links with Embedded Passwords -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). +### Architecture Overview -| 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 | +``` +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 +``` -> Reference: `backend/src/auth/`, `backend/src/security/`, `backend/src/models/audit_log.rs`. +### Implementation Design + +```rust +use rand::RngCore; + +#[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, +} + +pub struct ShareService { + encryption_service: EncryptionService, +} + +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() +} +``` --- -## 8. Threat model summary +## Password Recovery in Zero-Knowledge Systems -| 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 | +### Recovery with Zero-Knowledge Phrases + +Currently implemented in Normogen: + +```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 + } +} +``` --- -## References +## Security Best Practices -- 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` +### 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 + +--- + +## Comparison: Current vs Proposed + +### 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