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.
336 lines
17 KiB
Markdown
336 lines
17 KiB
Markdown
# 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.
|