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.
17 KiB
ADR: Multi-Person Sharing Under Zero-Knowledge
Status: Decided (not yet implemented)
Date: 2026-07-18
Discussion: issue #3
Related: 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)
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):
- 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.
- 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.)
- Revocation is a hard requirement. Cutting off a caregiver must take effect.
- 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:
- Owner's client looks up the recipient's X25519 public key
(
GET /api/users/:id/public-key, new). - Owner's client unwraps the target profile's DEK (via their account DEK).
- Generate an ephemeral X25519 keypair for this share.
- ECDH(ephemeral priv, recipient pub) → AES-GCM wrapping key.
- Wrap the profile DEK under that wrapping key →
{ciphertext, iv, ephemeralPubKey}. POST /api/profiles/:id/shareswith{recipientUserId, wrappedDek, ephemeralPubKey, permissions, expiresAt?}.- 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:
- New adult account (the graduated child) generates its X25519 keypair.
- Old owner wraps the profile DEK to the new account's pubkey as a share, and marks that share as the new owner share.
- Old owner's access is downgraded to (or removed as) a normal recipient share.
- 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
selfprofile; 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
Sharemodel and/api/sharesroutes 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. ReturnsprofileId.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 inCargo.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.tswill 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-keyreturns 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
-
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.
-
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.
-
Share record schema. ✅ Decided:
profile_sharescollection, schema as proposed, permissions included. Fields:{profileId, recipientUserId, ephemeralPubKey, wrappedDek, iv, permissions, expiresAt, active, createdAt}.permissionsis 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 ofadmin. -
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.)
-
Existing
Sharemodel fate. ✅ Decided: remove entirely.backend/src/models/share.rs, the/api/sharesroutes, and thepermissions/checkendpoint are deleted; the newprofile_sharescollection and/api/profiles/:id/sharesendpoints 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/sharesendpoints and soft revoke. - Phase C — hard revoke / re-key. The
/profiles/:id/rekeyendpoint 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
- Predecessor ADR:
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"}),deriveBitsfor ECDH,wrapKey/unwrapKeyor manual AES-GCM for the envelope.