docs: decide multi-person ZK sharing ADR; reconcile jwt/encryption docs
Some checks failed
Lint and Build / format (pull_request) Successful in 37s
Lint and Build / clippy (pull_request) Successful in 1m40s
Lint and Build / build (pull_request) Successful in 3m44s
Lint and Build / test (pull_request) Failing after 0s

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:
goose 2026-07-18 19:26:43 -03:00
parent 6a569da3b1
commit e322145ffb
6 changed files with 737 additions and 990 deletions

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.