design: multi-person / family / caregiver data sharing under zero-knowledge encryption is unsolved #3
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
The product's central persona use case — parent manages medications for child, shares with spouse / caregiver — is not achievable with the current architecture, and needs a design decision before family/caregiver features can ship.
The encryption model is strong (genuine ZK, server can't read data — see
docs/adr/zero-knowledge-encryption.md), but it creates a hard constraint that the persona/family docs hand-wave over: all of a user's data is encrypted under a single DEK that only that user can unwrap. There is currently no mechanism to let a second user decrypt any of it.What works today
profileIdis plaintext/queryable, soGET /api/medications?profile_id=Xfilters correctly. Good.What does NOT work / is not built
Sharing is broken for encrypted data.
backend/src/models/share.rs+/api/sharesroutes exist and store{resource_type, resource_id, target_user_id, permissions}. But the medication blob is encrypted under the owner's DEK. The recipient (target_user_id) has a different DEK and cannot decrypt the shared blob. Permissions on ciphertext the recipient can't open are meaningless.No key-distribution / re-wrapping flow. I see no mechanism in
web/.../crypto/or the backend for: encrypting a DEK (or per-resource key) to a recipient's public key, re-wrapping a resource key under a recipient's KEK, or any asymmetric envelope scheme. Without one of these, cross-user sharing is impossible.No multi-profile. Despite the persona doc's family vision,
ProfileRepository::find_by_user_idreturns a single profile andbuild_default_profilehardcodesprofile_{user_id}. There is no create-child / list-profiles / switch-profile. So even within one account, "manage my child's meds as a distinct person" is only partially supported (you can tag medications with aprofile_idstring, but there's no profile entity to back it beyond the owner's).No family enforcement. JWT carries
family_id+permissions(backend/src/auth/claims.rs) but nothing populates or checks them for data access. TheFamilymodel has no handler and no routes.Why this needs discussion, not just code
There are several viable approaches, each with real trade-offs. Picking one shapes the data model and the UX:
Questions for discussion
Proposal
Sharemodel as currently built should be treated as a placeholder until the key-distribution question is answered.Labels:
design,feature. Blocking for the family/caregiver persona work.Answers to questions plus extra info
Another use case: For kids, as time goes by, they will grow, and a profile may become its own user login. In that case, it may revoke the access to their parents.
Also, a profile may be a pet, that also have health information, lab results, medication, etc.
There is a future requirement about sharing specific health information. That's for the case of wanting to share some of the health information with a physician during/after an appointment. That should be handled via a link on the server, or, at first, dumping all requested information in a password-encrypted pdf. It is a much simpler case than the profile sharing discussed above.
Synthesis from @alvaro's answers (2026-07-18)
The constraints rule out most options and force a concrete architecture.
Ruled out
Forced: (a) per-profile DEK + asymmetric envelope
Because the server operator is fully untrusted, the server cannot broker symmetric keys. To share to another account without trusting the server, the recipient needs a public key the owner can encrypt to directly, client-side. So:
(profileId, recipientUserId).Implications for the profile model (from the new use cases)
roleenum should be reframed:kind(human|pet) + freeformrelationship(self,child,spouse,parent,pet, …). Currentrole: "patient"becomeskind: human, relationship: self.Out of scope (separate features)
Remaining sub-decisions (for the ADR)
(profileId, recipientUserId).password KEK → (wraps) → account DEK → (wraps each) → profile DEK → (encrypts) → profile data. This adds one tier over today's flataccount DEK → data. Alternative: wrap each profile DEK directly under the password KEK (shallower, but more re-wraps on password change). Slight preference for the 3-tier model.Proposed next step
Draft ADR: multi-person sharing under zero-knowledge covering the above, then implement behind it. @alvaro — OK to proceed with the ADR draft, and any steer on sub-decisions 1–4 (especially the primitive choice) before I write it?
Started the ADR draft at
docs/adr/multi-person-sharing.md(uncommitted, in the working tree).It records the decision forced by the constraints above (per-profile DEK + X25519 envelope), spells out the key hierarchy, sharing/revocation/graduation flows, what happens to the current
Sharemodel, and a 4-phase rollout (A: key tiers only → B: sharing → C: hard revoke → D: graduation).Five open questions are flagged ❓ in the doc — most needing your steer before implementation:
permissionsbelongs there at all)Sharemodel/routesReview the draft and comment here or inline. Once the ❓ items are resolved I'''ll mark the ADR
Status: Decidedand it becomes the reference for implementation.Correction to my earlier comment (and to issue #3 body): I wrote that the JWT carries
family_id+permissions. That'''s wrong.The real access-token
Claims(backend/src/auth/jwt.rs) are:No
family_id, nopermissions, nojtion access tokens.The richer
AccessClaims { ..., family_id, permissions, token_type, jti }inbackend/src/auth/claims.rsis dead code — not imported or used anywhere in the source. It'''s an aspirational struct that was never wired up.Implications for this issue:
AccessClaimsas-is.claims.rsis tracked under issue #4.Updated the JWT ADR (
docs/adr/jwt-authentication-decision.md) accordingly — rewritten to match the implementation.Open questions resolved; ADR status → Decided
Folded @alvaro's answers into
docs/adr/multi-person-sharing.mdand flipped its status to Decided (not yet implemented). Summary:profile_sharescollection with{profileId, recipientUserId, ephemeralPubKey, wrappedDek, iv, permissions, expiresAt, active, createdAt}; permissions included.Sharemodel → remove entirely;/api/sharesroutes andpermissions/checkdeleted. Physician-share feature will be rebuilt from scratch later on its own model.Elderly-parent use case (asked in thread)
Covered by the design, in all three setups:
One real sub-decision surfaced: what happens when the sole owner dies or becomes incapacitated? Without the owner's account DEK, no child can write/re-share/revoke. Resolved by adding an
adminpermission tier to shares: an admin share can re-share, re-key, and manage shares without the owner. Owner share remains special (can transfer ownership, admin cannot). New section §5c in the ADR.Deferred items (not blocking implementation)
Separate issue filed
#5 — remove the dead
AccessClaimsstruct inbackend/src/auth/claims.rs(discovered during the doc reconciliation).ADR is ready for implementation planning against the 4-phase rollout (A: key tiers → B: sharing → C: hard revoke → D: graduation). Keeping this issue open as the tracking anchor for that work.
Phase A1 landed (not yet merged) — PR #6
feat/3-phase-a1-x25519-keypairimplements the account X25519 identity keypair from the ADR §1:Usergetsidentity_public_key(plaintext),identity_private_key_wrapped+_iv(opaque, wrapped under the account DEK).RegisterRequest/AuthResponsecarry them; register + login echo them. No crypto deps added — server stores verbatim. New fields are allOption<String>so existing clients/tests stay green. Verified thatchange_passwordandrecover_passwordneed no changes (the account DEK value is unchanged across both, so the wrapped private key is too).crypto/keys.tsgainsgenerateIdentityKeyPair/wrapIdentityPrivateKey/unwrapIdentityPrivateKey+ an in-memory store mirroring the DEK.registergenerates+wraps,login/UnlockPageunwrap,logoutclears. Removed an existingas anycast in the process.The keypair is not consumed yet — that's Phase B. A1 only lays the groundwork: the keypair exists, round-trips, and unwraps into in-memory storage where Phase B will read it.
Verification:
cargo test(19 pass) +cargo clippy -D warningsclean;npm test(28 pass) +tsc --noEmitclean. PR #6 is mergeable.Next: Phase A2 (per-profile DEKs)
As decided in planning, A2 is a separate branch/PR. Scope: introduce the
account DEK → profile DEK → datatier, refactor the ~11 frontend encrypt call sites to thread the active profile's DEK explicitly (per the scoping decision), add multi-profile UI, and addprofile_idfiltering to appointments/health-stats. DB wipe per the ADR — no migration.Phase A2 landed (PR #9, not yet merged)
feat/3-phase-a2-per-profile-deksimplements the 3-tier key model from the ADR §1:GET/POST /api/profiles,GET/PUT/DELETE /api/profiles/:id(all ownership-scoped)./api/profiles/meremoved. Profile model gainsowner_account_id,kind(human/pet),relationship,wrapped_profile_dek.?profile_id=server-side.Per the ADR: DB wipe, no migration (no real user data).
Verification
cargo build+clippy --all-targets --all-features+fmt --checkclean. Tests compile. Newprofile_tests.rscovers multi-profile CRUD, ownership isolation, and register-with-default-profile.tscclean, 30/30 tests pass (2 new per-profile DEK isolation tests).Substrate is now in place for Phase B
With per-profile DEKs shipped, Phase B (actual sharing) has what it needs: a per-profile key to wrap to a recipient's X25519 public key. Phase B scope: the
profile_sharescollection,POST/GET/DELETE /profiles/:id/shares, soft revoke, and the ECDH envelope on the client.Phases C (hard revoke / re-key) and D (graduation / ownership transfer) remain after that. PR #9 does not close this issue — leaving #3 open as the tracking anchor.
Phase B landed (PR #11, not yet merged)
feat/3-phase-b-profile-sharingimplements the X25519-envelope sharing primitive from the ADR §3. An owner can now share a profile to another account; the recipient reads the profile's metadata and its data (medications, appointments, health stats) under their own login. The server stays a blind store — the owner wraps the profile DEK to the recipient's identity public key via ECDH (fresh ephemeral key per share); the recipient unwraps with their identity private key.Architecture: the share-gate
The data handlers hardcoded
userId == claims.sub, so a recipient couldn't read shared data even with the key. Fix: a handler-layer gate,authorize_profile_read(state, claims, profile_id), that returns the profile's owner userId if the caller owns it OR has an active share. List/get handlers call the gate whenprofile_idis specified, then query as the resolved owner. Data repos stay ownership-scoped; shares live entirely in the handler layer.What's in the PR
ProfileSharemodel + repo; share endpoints (POST/GET /profiles/:id/shares,DELETE /profiles/:id/shares/:recipient,GET /profiles/shared-with-me,GET /users/public-key); gate wired into meds/appointments/health-stats list+get.Sharesystem (ADR Open Q5): the old ACL-style shares + permissions + the dead permission middleware. ~7 files deleted; the newprofile_sharescollection replaces them.loadSharedWithMe+shareProfile/revokeSharein the store; ProfileSwitcher shows shared profiles with a chip; newProfileSharingUI for owner add/revoke; shared profiles render read-only.share_tests.rs: full owner→recipient→revoke flow, ownership isolation, share-to-self/nonexistent/keyless rejections, expired-share-ignored.Verification
Backend cargo build/clippy (
-D warnings)/fmt clean, tests compile. Frontend tsc clean, 31/31 tests pass. ⚠️ Integration tests didn't run locally (no Mongo in sandbox) — CI will run them; I'll fix any failures.Notable side effect (flagged in PR)
While wiring the gate I found
get_medication/get_appointmentpreviously took_claims(unused) — i.e. any authenticated user could read any med/appointment by id. The gate now enforces ownership-or-share on reads. The write paths (update_*/delete_*) still don't check ownership — a pre-existing hole, out of scope here, worth a follow-up issue.Phases remaining
AccessClaims).PR #11 does not close this issue — leaving #3 open as the tracking anchor.
✅ Phase B merged (PR #11 → main, commit
d59487f)The X25519-envelope sharing primitive is on
main. An owner can share a profile to another account; the recipient reads the profile's metadata AND its data (medications, appointments, health stats) under their own login, with the server staying a blind store.What landed
ProfileSharemodel + repo; share endpoints (POST/GET /profiles/:id/shares,DELETE /profiles/:id/shares/:recipient,GET /profiles/shared-with-me,GET /users/public-key); the share-gate (authorize_profile_read) wired into meds/appointments/health-stats list+get, with a direct-ownership fallback (so items whoseprofile_iddoesn't map to a Profile document still read for their owner).Sharesystem + dead permission middleware.share_tests.rscovering the full flow + edge cases.Two CI-caught bugs (both fixed before merge)
CI caught two real issues my Mongo-less sandbox couldn't — both instructive:
send_jsondoesn't build query strings) + a wrong assumption about the medication-create HTTP code.profile_idhad no backing Profile document. Fixed with a direct-ownership check before the gate.Side effect filed separately
While wiring the gate I found that
get_medication/get_appointmentpreviously did no ownership check at all (any authenticated user could read any record by id), and the write paths still don't — filed as #12 (security: IDOR on med/appointment update+delete).Phases remaining
AccessClaims).Keeping this issue open as the tracking anchor for C and D.
Phase C landed (PR #15, not yet merged)
feat/3-phase-c-rekeyimplements hard revoke / re-key (ADR §4). An owner can rotate a profile's DEK: generate a fresh key, re-encrypt all the profile's data under it (client-side), re-wrap to the owner share + kept recipients. A revoked recipient's cached old DEK stops working — closing the soft-revoke window the ADR requires for graduation / suspected compromise.What's in the PR
ProfileRepository::update_wrapped_dek(owner-share rotation);ProfileShareRepository::find_active_for_profile+delete_for_profile_excluding;POST /api/profiles/:id/rekey(validates envelopes against existing active shares, rotates owner share, upserts recipient envelopes with fresh ephemeral ECDH keys, hard-deletes omitted recipients).rekey_tests.rscovers rotation, hard-revoke-from-recipient-view, and the edge cases.useProfileStore.rekeyProfile(re-encrypts meds + appointments + health-stats under a new DEK, resume-safe — rows already on the new DEK are skipped; builds fresh ECDH envelopes per kept recipient; commits via POST /rekey; swaps the in-memory DEK).ProfileSharinggets a "Rotate encryption key" action with a strong confirmation dialog.Verification
Backend cargo build/clippy (
-D warnings)/fmt clean; tests compile (CI runs them — Mongo is fixed there). Frontend tsc clean, 31/31 tests pass. ⚠️ Integration tests not run locally (no Mongo in sandbox) — CI will run them; I'll fix any failures.Phase remaining
Keeping #3 open as the tracking anchor.
✅ Phase C merged (PR #15 → main, commit
dabbea2)Hard revoke / re-key is on
main. An owner can rotate a profile's DEK — re-encrypting all the profile's data client-side under a fresh key and re-wrapping to the owner share + kept recipients — so a revoked recipient's cached old DEK stops working. The server stays a blind store (opaque old→new blobs flow through, no crypto). Best-effort + resumable retry.CI note
CI caught one handler bug my Mongo-less sandbox couldn't:
rekey_profileechoed the pre-image of the wrapped DEK in its response (MongoDB'sfind_one_and_updatereturns the document before the update by default). Fixed by echoing the request values. The write was always correct; only the response was stale. All four CI jobs (format/clippy/build/test) now green — verified via theactions/tasksendpoint.ADR status — one phase left
mainPlus admin-initiated rekey (ADR §5c) lands with Phase D, since admin shares aren't creatable in the UI yet.
Keeping #3 open as the tracking anchor for Phase D.