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
336
docs/adr/multi-person-sharing.md
Normal file
336
docs/adr/multi-person-sharing.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue