docs: decide multi-person ZK sharing ADR; reconcile jwt/encryption docs
Rewrite the encryption/persona/JWT docs to match the implemented code and record the multi-person sharing design. - multi-person-sharing.md (NEW): ADR for per-profile DEK + X25519 envelope sharing. All five original open questions resolved 2026-07-18. Covers divorced-parents, pets, graduation, and elderly-parent care; adds an admin permission tier for owner-incapacity. Decided, not yet implemented. (#3) - encryption.md: full rewrite to the implemented wrapped-DEK / Web Crypto model; the old version described ZK encryption as 'planned'. (#4) - jwt-authentication-decision.md: rewritten to match implementation (PBKDF2 not bcrypt, no Redis, token_version revocation, real Claims). Original aspirational content preserved in a History section. (#4) - PERSONA_AND_FAMILY_MANAGEMENT.md: 'Current reality' section added, old 'Implementation Status' marked superseded, encryption section corrected. (#4) - ENCRYPTION_UPDATE_SUMMARY.md: archived (changelog for a rewrite that itself went stale). (#4) - ADR README index updated for the new + reconciled entries.
This commit is contained in:
parent
6a569da3b1
commit
e322145ffb
6 changed files with 737 additions and 990 deletions
|
|
@ -1,174 +1,185 @@
|
|||
# JWT Authentication Decision Summary
|
||||
# ADR: JWT Authentication
|
||||
|
||||
**Date**: 2026-02-14
|
||||
**Decision**: **JWT with Refresh Tokens + Recovery Phrases**
|
||||
**Status**: Implemented (current code is the source of truth)
|
||||
**Date**: 2026-02-14 (original decision), 2026-07-18 (reconciled with code)
|
||||
|
||||
---
|
||||
> This ADR was originally written during Phase 1 research and described several
|
||||
> things that were never built (bcrypt, Redis, family-role permission claims in
|
||||
> the JWT, the specific recovery-phrase flow). It has been rewritten to match
|
||||
> the implementation. The original reasoning is preserved in the "History"
|
||||
> section at the end. Where code and the old text disagree, **the code wins**.
|
||||
|
||||
## Authentication Strategy
|
||||
## Context
|
||||
|
||||
### Primary: JWT (JSON Web Tokens)
|
||||
Normogen authenticates accounts with email + password and authorizes API
|
||||
requests with stateless JWTs. This sits *underneath* the zero-knowledge
|
||||
encryption layer (see [`zero-knowledge-decryption.md`](./zero-knowledge-encryption.md)
|
||||
and [`encryption.md`](../product/encryption.md)): authentication proves who you
|
||||
are and lets you fetch your wrapped DEK; the client-side ZK layer then unlocks
|
||||
your data. The auth system never sees plaintext health data.
|
||||
|
||||
**Why JWT?**
|
||||
- Stateless design scales to 1000+ concurrent connections
|
||||
- Works perfectly with mobile apps (AsyncStorage)
|
||||
- No server-side session storage needed
|
||||
- Easy to scale Axum horizontally
|
||||
## Decision
|
||||
|
||||
### Token Types
|
||||
### Token model
|
||||
|
||||
**Access Token** (15 minutes)
|
||||
- Used for API requests
|
||||
- Short-lived for security
|
||||
- Contains: user_id, email, family_id, permissions
|
||||
Two JWTs, both HS256-signed with a shared secret from config:
|
||||
|
||||
**Refresh Token** (30 days)
|
||||
- Used to get new access tokens
|
||||
- Long-lived for convenience
|
||||
- Stored in MongoDB for revocation
|
||||
- Rotated on every refresh
|
||||
| Token | Lifetime | Claims | Purpose |
|
||||
|---|---|---|---|
|
||||
| Access | configurable (`JwtConfig.access_token_expiry_minutes`, default **15 min**) | `{sub, exp, iat, user_id, email, token_version}` | Sent on every API request |
|
||||
| Refresh | configurable (`JwtConfig.refresh_token_expiry_days`, default **30 days**) | `{sub, exp, iat, jti, user_id, token_version}` | Exchange for a new access token |
|
||||
|
||||
---
|
||||
> Reference: `backend/src/auth/jwt.rs` (`Claims`, `RefreshClaims`, `JwtService`).
|
||||
|
||||
## Token Revocation Strategies
|
||||
The lifetimes are **config-driven**, not hardcoded — the original ADR's "15 min
|
||||
/ 30 days" are the defaults, not constants.
|
||||
|
||||
### 1. Refresh Token Blacklist (Recommended) ⭐
|
||||
- Store refresh tokens in MongoDB
|
||||
- Mark as revoked on logout
|
||||
- Check on every refresh
|
||||
### Claims (actual, as implemented)
|
||||
|
||||
### 2. Token Versioning
|
||||
- Include version in JWT claims
|
||||
- Increment on password change
|
||||
- Invalidate all tokens when version changes
|
||||
The earlier ADR listed richer claims (`family_id`, `permissions`, `token_type`).
|
||||
**These are not in the shipped access token.** The real `Claims` struct is:
|
||||
|
||||
### 3. Access Token Blacklist (Optional)
|
||||
- Store revoked access tokens in Redis
|
||||
- For immediate revocation
|
||||
- Auto-expires with TTL
|
||||
```rust
|
||||
pub struct Claims { // access token
|
||||
pub sub: String, // = user_id (ObjectId hex)
|
||||
pub exp: usize,
|
||||
pub iat: usize,
|
||||
pub user_id: String,
|
||||
pub email: String,
|
||||
pub token_version: i32,
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
## Refresh Token Pattern
|
||||
|
||||
### Token Rotation (Security Best Practice) ⭐
|
||||
|
||||
**Flow**:
|
||||
1. Client sends refresh_token
|
||||
2. Server verifies refresh_token (not revoked, not expired)
|
||||
3. Server generates new access_token
|
||||
4. Server generates new refresh_token
|
||||
5. Server revokes old refresh_token
|
||||
6. Server returns new tokens
|
||||
|
||||
**Why?** Prevents reuse of stolen refresh tokens
|
||||
|
||||
---
|
||||
|
||||
## Zero-Knowledge Password Recovery
|
||||
|
||||
### Recovery Phrases (from encryption.md)
|
||||
|
||||
**Registration**:
|
||||
1. Client generates recovery phrase (random 32 bytes)
|
||||
2. Client encrypts recovery phrase with password
|
||||
3. Client sends: email, password hash, encrypted recovery phrase
|
||||
4. Server stores: email, password hash, encrypted recovery phrase
|
||||
|
||||
**Password Recovery**:
|
||||
1. User requests recovery (enters email)
|
||||
2. Server returns: encrypted recovery phrase
|
||||
3. Client decrypts with recovery key (user enters manually)
|
||||
4. User enters new password
|
||||
5. Client re-encrypts recovery phrase with new password
|
||||
6. Client sends: new password hash, re-encrypted recovery phrase
|
||||
7. Server updates: password hash, encrypted recovery phrase, token_version + 1
|
||||
8. All existing tokens invalidated (version mismatch)
|
||||
|
||||
---
|
||||
|
||||
## Family Member Access Control
|
||||
|
||||
### Permissions in JWT
|
||||
|
||||
```typescript
|
||||
// JWT permissions based on family role
|
||||
{
|
||||
"parent": [
|
||||
"read:own_data",
|
||||
"write:own_data",
|
||||
"read:family_data",
|
||||
"write:family_data",
|
||||
"manage:family_members",
|
||||
"delete:data"
|
||||
],
|
||||
"child": [
|
||||
"read:own_data",
|
||||
"write:own_data"
|
||||
],
|
||||
"elderly": [
|
||||
"read:own_data",
|
||||
"write:own_data",
|
||||
"read:family_data"
|
||||
]
|
||||
pub struct RefreshClaims { // refresh token
|
||||
pub sub: String,
|
||||
pub exp: usize,
|
||||
pub iat: usize,
|
||||
pub jti: String, // unique per token (see below)
|
||||
pub user_id: String,
|
||||
pub token_version: i32,
|
||||
}
|
||||
```
|
||||
|
||||
### Permission Middleware
|
||||
Note what is **absent** and why:
|
||||
|
||||
- Check permissions on protected routes
|
||||
- Return 403 Forbidden if insufficient permissions
|
||||
- Works with JWT claims
|
||||
- **No `family_id` or `permissions` in the token.** Family/permission
|
||||
enforcement is not implemented at the auth layer (see issue #3 — multi-person
|
||||
sharing is an open design problem). Putting unenforced claims in the token
|
||||
would imply protection that doesn't exist.
|
||||
- `backend/src/auth/claims.rs` *does* define an `AccessClaims` struct with
|
||||
`family_id`, `permissions`, `token_type`, `jti`. **It is dead code** — not
|
||||
imported or used anywhere in the source (only in stale compiler scratch
|
||||
files under `target/`). It should be removed or wired up; tracked by issue #4.
|
||||
- **No `token_type` discriminator.** Access and refresh claims are different
|
||||
structs, so a token can only decode as one or the other — the field is
|
||||
redundant and was dropped.
|
||||
|
||||
---
|
||||
### Refresh tokens: persistence, rotation, reuse detection
|
||||
|
||||
## Technology Stack
|
||||
- Refresh tokens are **stored in MongoDB** (`refresh_tokens` collection) — not
|
||||
Redis (the original ADR listed Redis as an option; it is not used).
|
||||
- The `jti` claim makes every refresh token unique. Without it, two refresh
|
||||
tokens issued in the same second for the same user would be byte-identical,
|
||||
which breaks rotation and reuse detection. (Covered by the
|
||||
`refresh_tokens_are_unique_even_in_same_second` test in `jwt.rs`.)
|
||||
- On refresh: validate signature + expiry, confirm the token exists and is not
|
||||
revoked in the repository, mint a new access + refresh pair, revoke the old
|
||||
refresh token (rotation).
|
||||
|
||||
### Backend (Axum)
|
||||
- jsonwebtoken 9.x (JWT crate)
|
||||
- bcrypt 0.15 (password hashing)
|
||||
- mongodb 3.0 (refresh token storage)
|
||||
- redis (optional, for access token blacklist)
|
||||
### Token revocation: versioning + session management
|
||||
|
||||
### Client (React Native + React)
|
||||
- AsyncStorage (token storage)
|
||||
- axios (API client with JWT interceptor)
|
||||
- PBKDF2 (password derivation)
|
||||
- AES-256-GCM (data encryption)
|
||||
Two mechanisms, both implemented:
|
||||
|
||||
---
|
||||
1. **`token_version` (global kill-switch).** Stored on the `User` document,
|
||||
incremented on password change and password recovery. The JWT middleware
|
||||
(`middleware/auth.rs`) rejects any access token whose `token_version` is
|
||||
stale against the user's current value. A short-lived `TokenVersionCache`
|
||||
avoids hitting Mongo on every request.
|
||||
2. **Session management.** Refresh tokens are tracked as sessions
|
||||
(`GET /api/sessions`, `DELETE /api/sessions/:id`, `DELETE /api/sessions/all`)
|
||||
so a user can list and revoke individual logins.
|
||||
|
||||
## Implementation Timeline
|
||||
### Password handling
|
||||
|
||||
- **Week 1**: Basic JWT (login, register, middleware)
|
||||
- **Week 1-2**: Refresh tokens (storage, rotation)
|
||||
- **Week 2**: Token revocation (blacklist, versioning)
|
||||
- **Week 2-3**: Password recovery (recovery phrases)
|
||||
- **Week 3**: Family access control (permissions)
|
||||
- **Week 3-4**: Security hardening (rate limiting, HTTPS)
|
||||
- **PBKDF2** (not bcrypt — the original ADR mentioned bcrypt), via the `pbkdf2`
|
||||
crate with a per-password random salt. (`backend/src/auth/password.rs`)
|
||||
- Under the ZK model, what the server hashes is the **auth secret** — a
|
||||
base64'd PBKDF2 derivation of the user's password produced client-side — not
|
||||
the raw password. The raw password never leaves the browser. See
|
||||
[`encryption.md`](../product/encryption.md) §2.
|
||||
|
||||
**Total**: 3-4 weeks
|
||||
### Password recovery
|
||||
|
||||
---
|
||||
Recovery is handled by the **zero-knowledge wrapped-DEK** mechanism, *not* by
|
||||
the "client encrypts the recovery phrase with the password" scheme the original
|
||||
ADR described. The client derives a recovery KEK from the recovery phrase,
|
||||
unwraps the DEK from the recovery-wrapped form, and re-wraps it under the new
|
||||
password. See [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md)
|
||||
§"Phase 2: Wrapped-DEK Recovery". The server bumps `token_version` on recovery,
|
||||
invalidating all prior tokens.
|
||||
|
||||
## Next Steps
|
||||
### What protects what
|
||||
|
||||
1. Implement basic JWT service in Axum
|
||||
2. Create MongoDB schema for users and refresh tokens
|
||||
3. Implement login/register/refresh/logout handlers
|
||||
4. Create JWT middleware for protected routes
|
||||
5. Implement token revocation (blacklist + versioning)
|
||||
6. Integrate password recovery (from encryption.md)
|
||||
7. Implement family access control (permissions)
|
||||
8. Test entire authentication flow
|
||||
9. Create client-side authentication (React Native + React)
|
||||
Auth + the ciphertext store are defended server-side; plaintext health data is
|
||||
never on the server to protect:
|
||||
|
||||
---
|
||||
| Layer | Mechanism |
|
||||
|---|---|
|
||||
| Password / auth secret | PBKDF2 + per-password salt |
|
||||
| Tokens | HS256 signature, short access TTL, refresh rotation + `jti` reuse detection |
|
||||
| Revocation | `token_version` (global) + session list (per-login) |
|
||||
| Brute force | Account lockout (5 attempts → exponential backoff) + rate limiting |
|
||||
| Forensics | Audit logging (auth attempts, authz checks) |
|
||||
|
||||
> Reference: `backend/src/auth/`, `backend/src/security/`, `backend/src/middleware/`.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Stateless access tokens scale horizontally (no DB lookup per request except
|
||||
the cached version check).
|
||||
- `token_version` gives instant global revocation on password change/recovery
|
||||
without maintaining a blocklist of outstanding tokens.
|
||||
- Refresh rotation + `jti` detects token theft: a rotated-away token being
|
||||
presented again signals reuse.
|
||||
|
||||
### Negative / costs
|
||||
|
||||
- `token_version` revocation is **coarse** — it kills *all* of a user's
|
||||
sessions at once. Per-session revocation is only available via the session
|
||||
list (refresh-token deletion).
|
||||
- HS256 with a shared secret means the secret is sensitive infrastructure; a
|
||||
key rotation requires invalidating all tokens (acceptable for the deployment
|
||||
model, but worth noting vs. asymmetric RS/ES keys).
|
||||
- The dead `AccessClaims` struct is a latent trap — someone could wire it up
|
||||
assuming `family_id`/`permissions` are enforced. Should be deleted.
|
||||
|
||||
## Open items
|
||||
|
||||
- **Remove `backend/src/auth/claims.rs`** (dead `AccessClaims`) or wire it up
|
||||
intentionally. Tracked under issue #4.
|
||||
- When issue #3 (multi-person sharing) is decided, revisit whether family /
|
||||
share authorization belongs in the JWT or is enforced per-request against a
|
||||
shares collection.
|
||||
|
||||
## History (original 2026-02-14 decision, superseded where noted)
|
||||
|
||||
The original ADR proposed: JWT with refresh tokens; bcrypt; optional Redis for
|
||||
access-token blacklists; recovery by client-encrypting the recovery phrase with
|
||||
the password; and `family_id`/`permissions` claims with parent/child/elderly
|
||||
role matrices. Of these, only **JWT with refresh tokens + rotation** was built
|
||||
as described. Password hashing uses PBKDF2 (not bcrypt), there is no Redis,
|
||||
recovery uses the wrapped-DEK scheme (not phrase-encryption), and the family/
|
||||
permission claims were never wired into the real `Claims`. This section is kept
|
||||
as a record of the original reasoning; the sections above are authoritative.
|
||||
|
||||
## References
|
||||
|
||||
- [Comprehensive JWT Research](./2026-02-14-jwt-authentication-research.md)
|
||||
- [Normogen Encryption Guide](../encryption.md)
|
||||
- [JWT RFC 7519](https://tools.ietf.org/html/rfc7519)
|
||||
- [Axum JWT Guide](https://docs.rs/axum/latest/axum/)
|
||||
- [OWASP JWT Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html)
|
||||
- Code: `backend/src/auth/jwt.rs`, `backend/src/auth/password.rs`,
|
||||
`backend/src/middleware/auth.rs`, `backend/src/auth/token_version_cache.rs`,
|
||||
`backend/src/models/refresh_token.rs`, `backend/src/models/session.rs`
|
||||
- Related ADRs: [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md),
|
||||
[`multi-person-sharing.md`](./multi-person-sharing.md) (draft)
|
||||
- Product doc: [`encryption.md`](../product/encryption.md)
|
||||
- Related issues: [#3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3),
|
||||
[#4](https://gitea.soliverez.com.ar/alvaro/normogen/issues/4)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue