Merge pull request 'docs: decide multi-person ZK sharing ADR; reconcile jwt/encryption docs (#4)' (#10) from docs/3-4-encryption-persona-docs-reconciliation into main
All checks were successful
Lint and Build / format (push) Successful in 34s
Lint and Build / clippy (push) Successful in 1m40s
Lint and Build / build (push) Successful in 3m47s
Lint and Build / test (push) Successful in 3m21s

Reviewed-on: #10
This commit is contained in:
alvaro 2026-07-19 09:47:18 +00:00
commit 015a99a7fe
6 changed files with 737 additions and 990 deletions

View file

@ -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 | | [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 | | [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 | | [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** | | [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/`) | | [monorepo-structure.md](./monorepo-structure.md) | Repository layout (`backend/`, `web/`, `mobile/`, `docs/`) |
| [backend-deployment-constraints.md](./backend-deployment-constraints.md) | Deployment requirements (Solaria, Docker) | | [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) | | [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 | | [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 > **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 > management), the code is the source of truth and the ADR is kept only as

View file

@ -1,174 +1,185 @@
# JWT Authentication Decision Summary # ADR: JWT Authentication
**Date**: 2026-02-14 **Status**: Implemented (current code is the source of truth)
**Decision**: **JWT with Refresh Tokens + Recovery Phrases** **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?** ## Decision
- 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 Types ### Token model
**Access Token** (15 minutes) Two JWTs, both HS256-signed with a shared secret from config:
- Used for API requests
- Short-lived for security
- Contains: user_id, email, family_id, permissions
**Refresh Token** (30 days) | Token | Lifetime | Claims | Purpose |
- Used to get new access tokens |---|---|---|---|
- Long-lived for convenience | Access | configurable (`JwtConfig.access_token_expiry_minutes`, default **15 min**) | `{sub, exp, iat, user_id, email, token_version}` | Sent on every API request |
- Stored in MongoDB for revocation | Refresh | configurable (`JwtConfig.refresh_token_expiry_days`, default **30 days**) | `{sub, exp, iat, jti, user_id, token_version}` | Exchange for a new access token |
- Rotated on every refresh
--- > 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) ⭐ ### Claims (actual, as implemented)
- Store refresh tokens in MongoDB
- Mark as revoked on logout
- Check on every refresh
### 2. Token Versioning The earlier ADR listed richer claims (`family_id`, `permissions`, `token_type`).
- Include version in JWT claims **These are not in the shipped access token.** The real `Claims` struct is:
- Increment on password change
- Invalidate all tokens when version changes
### 3. Access Token Blacklist (Optional) ```rust
- Store revoked access tokens in Redis pub struct Claims { // access token
- For immediate revocation pub sub: String, // = user_id (ObjectId hex)
- Auto-expires with TTL pub exp: usize,
pub iat: usize,
pub user_id: String,
pub email: String,
pub token_version: i32,
}
--- pub struct RefreshClaims { // refresh token
pub sub: String,
## Refresh Token Pattern pub exp: usize,
pub iat: usize,
### Token Rotation (Security Best Practice) ⭐ pub jti: String, // unique per token (see below)
pub user_id: String,
**Flow**: pub token_version: i32,
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"
]
} }
``` ```
### Permission Middleware Note what is **absent** and why:
- Check permissions on protected routes - **No `family_id` or `permissions` in the token.** Family/permission
- Return 403 Forbidden if insufficient permissions enforcement is not implemented at the auth layer (see issue #3 — multi-person
- Works with JWT claims 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) ### Token revocation: versioning + session management
- jsonwebtoken 9.x (JWT crate)
- bcrypt 0.15 (password hashing)
- mongodb 3.0 (refresh token storage)
- redis (optional, for access token blacklist)
### Client (React Native + React) Two mechanisms, both implemented:
- 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.
## Implementation Timeline ### Password handling
- **Week 1**: Basic JWT (login, register, middleware) - **PBKDF2** (not bcrypt — the original ADR mentioned bcrypt), via the `pbkdf2`
- **Week 1-2**: Refresh tokens (storage, rotation) crate with a per-password random salt. (`backend/src/auth/password.rs`)
- **Week 2**: Token revocation (blacklist, versioning) - Under the ZK model, what the server hashes is the **auth secret** — a
- **Week 2-3**: Password recovery (recovery phrases) base64'd PBKDF2 derivation of the user's password produced client-side — not
- **Week 3**: Family access control (permissions) the raw password. The raw password never leaves the browser. See
- **Week 3-4**: Security hardening (rate limiting, HTTPS) [`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 Auth + the ciphertext store are defended server-side; plaintext health data is
2. Create MongoDB schema for users and refresh tokens never on the server to protect:
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 ## References
- [Comprehensive JWT Research](./2026-02-14-jwt-authentication-research.md) - Code: `backend/src/auth/jwt.rs`, `backend/src/auth/password.rs`,
- [Normogen Encryption Guide](../encryption.md) `backend/src/middleware/auth.rs`, `backend/src/auth/token_version_cache.rs`,
- [JWT RFC 7519](https://tools.ietf.org/html/rfc7519) `backend/src/models/refresh_token.rs`, `backend/src/models/session.rs`
- [Axum JWT Guide](https://docs.rs/axum/latest/axum/) - Related ADRs: [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md),
- [OWASP JWT Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html) [`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)

View file

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

View file

@ -1,5 +1,13 @@
# Encryption.md Update Summary # 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 **Date**: 2026-03-09
**File**: docs/product/encryption.md **File**: docs/product/encryption.md
**Update**: Added Rust implementation examples and current status **Update**: Added Rust implementation examples and current status

View file

@ -1,10 +1,16 @@
# Persona and Family Management - Product Definition # Persona and Family Management - Product Definition
**Document Version**: 1.0 **Document Version**: 1.1
**Date**: 2026-03-09 **Date**: 2026-07-18 (reconciled with code; original draft 2026-03-09)
**Status**: Draft - For Refinement **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 **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 ## 📋 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 ## User Personas
### Primary: Privacy-Conscious Individual ### 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) ### ✅ Implemented (Backend)
@ -595,16 +628,23 @@ if profile.role == "child" || profile.age < 18 {
### Encryption ### Encryption
**Zero-Knowledge Encryption**: > **Correction (2026-07-18):** the bullets below described a planned
- Profile names are encrypted (AES-256-GCM) > server-side scheme that was never built. The shipped system is
- Family names are encrypted > **client-side** zero-knowledge via the browser's Web Crypto API with a
- Only users with correct password can decrypt > wrapped-DEK model. The server has no crypto code at all — it is a blind
- Server cannot see profile names > 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**: **What's actually encrypted (client-side, today):**
- `profile.name` - Person's name - Medication data blob (name, dosage, frequency, route, notes, …)
- `family.name` - Family group name - Appointment data blob (title, provider, date/time, …)
- Medication data (when implemented) - Profile display name
**What stays plaintext (queryable by the server):**
- IDs (`profileId`, `medicationId`, `userId`, `appointmentId`)
- Medication `active` flag, appointment `status`, `doseSchedule`, timestamps
--- ---

File diff suppressed because it is too large Load diff