design: multi-person / family / caregiver data sharing under zero-knowledge encryption is unsolved #3

Open
opened 2026-07-18 15:24:40 +00:00 by alvaro · 11 comments
Owner

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

  • profileId is plaintext/queryable, so GET /api/medications?profile_id=X filters correctly. Good.
  • Single-user ZK flow (login → unwrap DEK → encrypt/decrypt own data) is complete and works.

What does NOT work / is not built

  1. Sharing is broken for encrypted data. backend/src/models/share.rs + /api/shares routes 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.

  2. 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.

  3. No multi-profile. Despite the persona doc's family vision, ProfileRepository::find_by_user_id returns a single profile and build_default_profile hardcodes profile_{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 a profile_id string, but there's no profile entity to back it beyond the owner's).

  4. No family enforcement. JWT carries family_id + permissions (backend/src/auth/claims.rs) but nothing populates or checks them for data access. The Family model 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:

  • (a) Per-profile DEKs + asymmetric envelope. Each profile gets its own data key; to share, wrap that key to the recipient's public key (X25519). Most flexible, most work, enables true caregiver sharing.
  • (b) Family-level shared key. A family group has a shared DEK; members derive/join it. Simpler, but coarser-grained and harder to revoke.
  • (c) Re-encrypt-on-share. Owner decrypts, re-encrypts under recipient's DEK, server stores a second ciphertext. Simple conceptually, but duplicative storage and revocation = delete the copy (no real revocation of already-seen data).
  • (d) Defer sharing; ship multi-profile-only first. Let one account manage several profiles under one DEK (no cross-account sharing yet). Smallest scope, unblocks the parent/child use case within a single account.

Questions for discussion

  1. Is the parent→child use case meant to be within one account (one login, multiple profiles) or across accounts (parent and spouse each have their own login)? This determines whether we even need cross-account sharing for MVP.
  2. If cross-account: do recipients always have Normogen accounts, or do we need magic-link / no-account sharing (the persona doc lists this as an open question)?
  3. Is revocation a hard requirement (must be able to cut off a caregiver and have it take effect)? This rules out option (c).
  4. What's the threat model for the server operator — fully untrusted (then sharing must be purely client-side key exchange), or honest-but-curious?

Proposal

  • Decide between (d) multi-profile-first vs (a) full asymmetric envelope as the near-term target.
  • Whatever is chosen, write it up as an ADR before implementing (the existing ZK ADR is a good model).
  • The Share model 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.

## 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 - `profileId` is plaintext/queryable, so `GET /api/medications?profile_id=X` filters correctly. Good. - Single-user ZK flow (login → unwrap DEK → encrypt/decrypt own data) is complete and works. ## What does NOT work / is not built 1. **Sharing is broken for encrypted data.** `backend/src/models/share.rs` + `/api/shares` routes 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. 2. **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. 3. **No multi-profile.** Despite the persona doc's family vision, `ProfileRepository::find_by_user_id` returns a single profile and `build_default_profile` hardcodes `profile_{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 a `profile_id` string, but there's no profile entity to back it beyond the owner's). 4. **No family enforcement.** JWT carries `family_id` + `permissions` (`backend/src/auth/claims.rs`) but nothing populates or checks them for data access. The `Family` model 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: - **(a) Per-profile DEKs + asymmetric envelope.** Each profile gets its own data key; to share, wrap that key to the recipient's public key (X25519). Most flexible, most work, enables true caregiver sharing. - **(b) Family-level shared key.** A family group has a shared DEK; members derive/join it. Simpler, but coarser-grained and harder to revoke. - **(c) Re-encrypt-on-share.** Owner decrypts, re-encrypts under recipient's DEK, server stores a second ciphertext. Simple conceptually, but duplicative storage and revocation = delete the copy (no real revocation of already-seen data). - **(d) Defer sharing; ship multi-profile-only first.** Let one account manage several profiles under one DEK (no cross-account sharing yet). Smallest scope, unblocks the parent/child use case within a single account. ## Questions for discussion 1. Is the parent→child use case meant to be **within one account** (one login, multiple profiles) or **across accounts** (parent and spouse each have their own login)? This determines whether we even need cross-account sharing for MVP. 2. If cross-account: do recipients always have Normogen accounts, or do we need magic-link / no-account sharing (the persona doc lists this as an open question)? 3. Is revocation a hard requirement (must be able to cut off a caregiver and have it take effect)? This rules out option (c). 4. What's the threat model for the server operator — fully untrusted (then sharing must be purely client-side key exchange), or honest-but-curious? ## Proposal - Decide between **(d) multi-profile-first** vs **(a) full asymmetric envelope** as the near-term target. - Whatever is chosen, write it up as an ADR *before* implementing (the existing ZK ADR is a good model). - The `Share` model 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.
alvaro added the
design
feature
labels 2026-07-18 15:25:02 +00:00
Author
Owner

Answers to questions plus extra info

  1. parent and spouse each will have their login, and they may not even be the same family. Divorced parents with each their own family may want to share the profile of their common kid, but nothing else
  2. the case considers that people will have normogen accounts. There will a way to share information, but it will be handled differently and will be specific on what is shared (ie. more like dumping certain docs in a secure mailbox than sharing the whole profile)
  3. revocation is a hard requirement
  4. the server operator is fully untrusted

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.

Answers to questions plus extra info 1. parent and spouse each will have their login, and they may not even be the same family. Divorced parents with each their own family may want to share the profile of their common kid, but nothing else 2. the case considers that people will have normogen accounts. There will a way to share information, but it will be handled differently and will be specific on what is shared (ie. more like dumping certain docs in a secure mailbox than sharing the whole profile) 3. revocation is a hard requirement 4. the server operator is fully untrusted 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.
Author
Owner

Synthesis from @alvaro's answers (2026-07-18)

The constraints rule out most options and force a concrete architecture.

Ruled out

  • (b) family-level shared key — incompatible with the divorced-parents case. Sharing one common kid's profile and nothing else demands per-profile key granularity; a family key leaks every family member's data to an ex-spouse.
  • (c) re-encrypt-on-share — incompatible with hard revocation. Revoking only deletes the server-side copy; it can't undo data the recipient already decrypted, and a cached re-encrypted blob keeps working offline. Hard revocation needs the key to stop working, not the copy to disappear.
  • (d) multi-profile-only as an alternative — insufficient. Cross-account sharing is required, so the key-distribution layer is needed regardless. Multi-profile-within-an-account is still useful, but as a milestone inside (a), not a substitute for it.

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:

  1. Each profile gets its own random profile DEK (not derived from the account password).
  2. Each user account gets an asymmetric keypair (proposed: X25519). The private half is wrapped under the password KEK and stored on the server (same pattern as the existing wrapped DEK — re-wrapped on password change / recovery). The public half is stored in plaintext (public keys are not secret).
  3. The owner accesses their own profiles by unwrapping each profile DEK with their account DEK.
  4. To share a profile, the owner wraps that profile's DEK to the recipient's public key (ECDH-derived wrapping key) and stores the wrapped blob on the server, indexed by (profileId, recipientUserId).
  5. Revocation = delete the wrapped-key entry. To make revocation robust against a client that cached the profile DEK, the owner can additionally re-key the profile (new DEK, re-encrypt data, re-wrap to still-valid recipients). Cached keys from revoked users then can't decrypt future writes.

Implications for the profile model (from the new use cases)

  • Profiles are "subjects of care," not human roles. Adding pets means the role enum should be reframed: kind (human | pet) + freeform relationship (self, child, spouse, parent, pet, …). Current role: "patient" becomes kind: human, relationship: self.
  • Graduation (kid → independent user). A profile is owned by one account; other accounts have revocable shares. "Graduation" = transfer ownership: the new adult generates a keypair, the parent wraps the profile DEK to the new adult's public key as the new owner-share, and the parent's access becomes an ordinary share that the new owner can revoke. This needs a notion of profile ownership transfer but no new crypto — it's the same wrap/unwrap machinery with an ownership flag.

Out of scope (separate features)

  • Physician share via link / encrypted PDF is a different, simpler mechanism (one-way dump of selected documents, password-encrypted, no ongoing access). Not served by the envelope scheme above; should be its own feature and its own issue when prioritized.

Remaining sub-decisions (for the ADR)

  1. Asymmetric primitive: X25519 (ECDH). Decided. Each account gets an X25519 keypair; private half wrapped under the password KEK (same pattern as the existing wrapped DEK), public half stored plaintext. To share, owner derives an ECDH shared secret from their ephemeral/identity key + recipient pubkey, uses it as an AES-GCM wrapping key, and stores the wrapped profile DEK indexed by (profileId, recipientUserId).
  2. Key hierarchy depth. Proposed: password KEK → (wraps) → account DEK → (wraps each) → profile DEK → (encrypts) → profile data. This adds one tier over today's flat account 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.
  3. Migration. Today all data sits under one account DEK. Moving to per-profile DEKs requires re-keying. Per the ZK ADR there's no real user data yet, so the migration is cheap now and expensive later — argument for doing it soon.
  4. Share granularity vs. profile granularity. Confirm: shares are at the profile level (share the whole profile), not per-resource. (Per-resource sharing = future, much more complex.) The physician-PDF case covers the "share one document" need separately.

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?

## Synthesis from @alvaro's answers (2026-07-18) The constraints rule out most options and force a concrete architecture. ### Ruled out - **(b) family-level shared key** — incompatible with the divorced-parents case. Sharing one common kid's profile *and nothing else* demands per-profile key granularity; a family key leaks every family member's data to an ex-spouse. - **(c) re-encrypt-on-share** — incompatible with hard revocation. Revoking only deletes the server-side copy; it can't undo data the recipient already decrypted, and a cached re-encrypted blob keeps working offline. Hard revocation needs the *key* to stop working, not the *copy* to disappear. - **(d) multi-profile-only as an alternative** — insufficient. Cross-account sharing is required, so the key-distribution layer is needed regardless. Multi-profile-within-an-account is still useful, but as a milestone *inside* (a), not a substitute for it. ### 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: 1. Each **profile** gets its own random **profile DEK** (not derived from the account password). 2. Each **user account** gets an **asymmetric keypair** (proposed: X25519). The private half is wrapped under the password KEK and stored on the server (same pattern as the existing wrapped DEK — re-wrapped on password change / recovery). The public half is stored in plaintext (public keys are not secret). 3. The owner accesses their own profiles by unwrapping each profile DEK with their account DEK. 4. To **share** a profile, the owner wraps that profile's DEK to the recipient's public key (ECDH-derived wrapping key) and stores the wrapped blob on the server, indexed by `(profileId, recipientUserId)`. 5. **Revocation** = delete the wrapped-key entry. To make revocation robust against a client that cached the profile DEK, the owner can additionally **re-key** the profile (new DEK, re-encrypt data, re-wrap to still-valid recipients). Cached keys from revoked users then can't decrypt future writes. ### Implications for the profile model (from the new use cases) - **Profiles are "subjects of care," not human roles.** Adding pets means the `role` enum should be reframed: `kind` (`human` | `pet`) + freeform `relationship` (`self`, `child`, `spouse`, `parent`, `pet`, …). Current `role: "patient"` becomes `kind: human, relationship: self`. - **Graduation (kid → independent user).** A profile is owned by one account; other accounts have revocable shares. "Graduation" = transfer ownership: the new adult generates a keypair, the parent wraps the profile DEK to the new adult's public key as the new owner-share, and the parent's access becomes an ordinary share that the new owner can revoke. This needs a notion of **profile ownership transfer** but no new crypto — it's the same wrap/unwrap machinery with an ownership flag. ### Out of scope (separate features) - **Physician share via link / encrypted PDF** is a different, simpler mechanism (one-way dump of selected documents, password-encrypted, no ongoing access). Not served by the envelope scheme above; should be its own feature and its own issue when prioritized. ### Remaining sub-decisions (for the ADR) 1. **Asymmetric primitive: X25519 (ECDH).** ✅ Decided. Each account gets an X25519 keypair; private half wrapped under the password KEK (same pattern as the existing wrapped DEK), public half stored plaintext. To share, owner derives an ECDH shared secret from their ephemeral/identity key + recipient pubkey, uses it as an AES-GCM wrapping key, and stores the wrapped profile DEK indexed by `(profileId, recipientUserId)`. 2. **Key hierarchy depth.** Proposed: `password KEK → (wraps) → account DEK → (wraps each) → profile DEK → (encrypts) → profile data`. This adds one tier over today's flat `account 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. 3. **Migration.** Today all data sits under one account DEK. Moving to per-profile DEKs requires re-keying. Per the ZK ADR there's no real user data yet, so the migration is cheap **now** and expensive later — argument for doing it soon. 4. **Share granularity vs. profile granularity.** Confirm: shares are at the **profile** level (share the whole profile), not per-resource. (Per-resource sharing = future, much more complex.) The physician-PDF case covers the "share one document" need separately. ### 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?
Author
Owner

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 Share model, 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:

  1. 3-tier key hierarchy vs wrapping profile DEKs directly under the password KEK
  2. Whether recipient verifies owner public key out-of-band (or we accept server key-swap as DoS-only)
  3. Share record schema (and whether permissions belongs there at all)
  4. Migration plan (cheap now, expensive later)
  5. Fate of the existing Share model/routes

Review the draft and comment here or inline. Once the items are resolved I'''ll mark the ADR Status: Decided and it becomes the reference for implementation.

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 `Share` model, 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: 1. 3-tier key hierarchy vs wrapping profile DEKs directly under the password KEK 2. Whether recipient verifies owner public key out-of-band (or we accept server key-swap as DoS-only) 3. Share record schema (and whether `permissions` belongs there at all) 4. Migration plan (cheap now, expensive later) 5. Fate of the existing `Share` model/routes Review the draft and comment here or inline. Once the ❓ items are resolved I'''ll mark the ADR `Status: Decided` and it becomes the reference for implementation.
Author
Owner

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:

{ sub, exp, iat, user_id, email, token_version }

No family_id, no permissions, no jti on access tokens.

The richer AccessClaims { ..., family_id, permissions, token_type, jti } in backend/src/auth/claims.rs is dead code — not imported or used anywhere in the source. It'''s an aspirational struct that was never wired up.

Implications for this issue:

  • The "no family enforcement" point stands and is actually stronger: there is no family/permission concept in the auth layer at all, not even an unused claim.
  • When we implement sharing, family/share authorization should be designed fresh (per-request check against a shares collection, or a deliberately-designed claim) rather than reviving AccessClaims as-is.
  • Removing claims.rs is tracked under issue #4.

Updated the JWT ADR (docs/adr/jwt-authentication-decision.md) accordingly — rewritten to match the 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: ``` { sub, exp, iat, user_id, email, token_version } ``` No `family_id`, no `permissions`, no `jti` on access tokens. The richer `AccessClaims { ..., family_id, permissions, token_type, jti }` in `backend/src/auth/claims.rs` is **dead code** — not imported or used anywhere in the source. It'''s an aspirational struct that was never wired up. Implications for this issue: - The "no family enforcement" point stands and is actually stronger: there is no family/permission concept in the auth layer at all, not even an unused claim. - When we implement sharing, family/share authorization should be designed fresh (per-request check against a shares collection, or a deliberately-designed claim) rather than reviving `AccessClaims` as-is. - Removing `claims.rs` is tracked under issue #4. Updated the JWT ADR (`docs/adr/jwt-authentication-decision.md`) accordingly — rewritten to match the implementation.
Author
Owner

Open questions resolved; ADR status → Decided

Folded @alvaro's answers into docs/adr/multi-person-sharing.md and flipped its status to Decided (not yet implemented). Summary:

  1. Key hierarchy → 3-tier. Rationale recorded: account DEK recovery stays O(1) on password change; profile DEKs only re-wrap on share/revoke/rekey events.
  2. Public-key trust → accept key-swap as DoS-only for now. Deferred enhancement: out-of-band pubkey verification via fingerprint/safety-number, recovery-phrase-embedded fingerprint, or TOFU pinning. Additive — ships later without migration. Candidates enumerated in the ADR.
  3. Share schemaprofile_shares collection with {profileId, recipientUserId, ephemeralPubKey, wrappedDek, iv, permissions, expiresAt, active, createdAt}; permissions included.
  4. Migration → wipe the DB, no migration. Phase A is a clean cutover.
  5. Old Share model → remove entirely; /api/shares routes and permissions/check deleted. 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:

  • Parent self-manages + shares to children → standard share flow.
  • Dedicated parent account, managing child holds the password → same machinery, preserves parent as identity of record.
  • Parent embedded as a profile in the managing child's account → works but discouraged (parent can't revoke).

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 admin permission 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)

  • Pubkey verification mechanism (item 2 above).
  • Admin re-key quorum — for MVP any admin acts alone; revisit if multi-party control becomes a requirement.

Separate issue filed

#5 — remove the dead AccessClaims struct in backend/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.

## Open questions resolved; ADR status → Decided Folded @alvaro's answers into `docs/adr/multi-person-sharing.md` and flipped its status to **Decided (not yet implemented)**. Summary: 1. **Key hierarchy** → 3-tier. Rationale recorded: account DEK recovery stays O(1) on password change; profile DEKs only re-wrap on share/revoke/rekey events. 2. **Public-key trust** → accept key-swap as DoS-only for now. **Deferred enhancement:** out-of-band pubkey verification via fingerprint/safety-number, recovery-phrase-embedded fingerprint, or TOFU pinning. Additive — ships later without migration. Candidates enumerated in the ADR. 3. **Share schema** → `profile_shares` collection with `{profileId, recipientUserId, ephemeralPubKey, wrappedDek, iv, permissions, expiresAt, active, createdAt}`; permissions included. 4. **Migration** → wipe the DB, no migration. Phase A is a clean cutover. 5. **Old `Share` model** → remove entirely; `/api/shares` routes and `permissions/check` deleted. 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: - Parent self-manages + shares to children → standard share flow. - Dedicated parent account, managing child holds the password → same machinery, preserves parent as identity of record. - Parent embedded as a profile in the managing child's account → works but discouraged (parent can't revoke). **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 **`admin` permission 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) - Pubkey verification mechanism (item 2 above). - Admin re-key quorum — for MVP any admin acts alone; revisit if multi-party control becomes a requirement. ## Separate issue filed #5 — remove the dead `AccessClaims` struct in `backend/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.
Author
Owner

Phase A1 landed (not yet merged) — PR #6

feat/3-phase-a1-x25519-keypair implements the account X25519 identity keypair from the ADR §1:

  • Backend: User gets identity_public_key (plaintext), identity_private_key_wrapped + _iv (opaque, wrapped under the account DEK). RegisterRequest/AuthResponse carry them; register + login echo them. No crypto deps added — server stores verbatim. New fields are all Option<String> so existing clients/tests stay green. Verified that change_password and recover_password need no changes (the account DEK value is unchanged across both, so the wrapped private key is too).
  • Frontend: crypto/keys.ts gains generateIdentityKeyPair / wrapIdentityPrivateKey / unwrapIdentityPrivateKey + an in-memory store mirroring the DEK. register generates+wraps, login/UnlockPage unwrap, logout clears. Removed an existing as any cast in the process.
  • Tests: backend integration test for register→login round-trip + backward-compat; 3 frontend crypto tests for the X25519 lifecycle (skip gracefully where the jsdom/Node runtime lacks X25519 Web Crypto).

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 warnings clean; npm test (28 pass) + tsc --noEmit clean. 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 → data tier, refactor the ~11 frontend encrypt call sites to thread the active profile's DEK explicitly (per the scoping decision), add multi-profile UI, and add profile_id filtering to appointments/health-stats. DB wipe per the ADR — no migration.

## Phase A1 landed (not yet merged) — PR #6 [`feat/3-phase-a1-x25519-keypair`](../pulls/6) implements the account X25519 identity keypair from the ADR §1: - **Backend**: `User` gets `identity_public_key` (plaintext), `identity_private_key_wrapped` + `_iv` (opaque, wrapped under the account DEK). `RegisterRequest`/`AuthResponse` carry them; register + login echo them. No crypto deps added — server stores verbatim. New fields are all `Option<String>` so existing clients/tests stay green. Verified that `change_password` and `recover_password` need no changes (the account DEK value is unchanged across both, so the wrapped private key is too). - **Frontend**: `crypto/keys.ts` gains `generateIdentityKeyPair` / `wrapIdentityPrivateKey` / `unwrapIdentityPrivateKey` + an in-memory store mirroring the DEK. `register` generates+wraps, `login`/`UnlockPage` unwrap, `logout` clears. Removed an existing `as any` cast in the process. - **Tests**: backend integration test for register→login round-trip + backward-compat; 3 frontend crypto tests for the X25519 lifecycle (skip gracefully where the jsdom/Node runtime lacks X25519 Web Crypto). 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 warnings` clean; `npm test` (28 pass) + `tsc --noEmit` clean. 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 → data` tier, refactor the ~11 frontend encrypt call sites to thread the active profile's DEK explicitly (per the scoping decision), add multi-profile UI, and add `profile_id` filtering to appointments/health-stats. DB wipe per the ADR — no migration.
Author
Owner

Phase A2 landed (PR #9, not yet merged)

feat/3-phase-a2-per-profile-deks implements the 3-tier key model from the ADR §1:

  • Per-profile DEKs: each profile (a person or pet — a "subject of care") gets its own random AES-256-GCM DEK. All health data is now encrypted under the active profile's DEK. The account DEK wraps each profile DEK; the server stores only opaque wrapped blobs.
  • Multi-profile: one account owns many profiles. New GET/POST /api/profiles, GET/PUT/DELETE /api/profiles/:id (all ownership-scoped). /api/profiles/me removed. Profile model gains owner_account_id, kind (human/pet), relationship, wrapped_profile_dek.
  • Profile switcher UI in the Dashboard AppBar; ProfileEditor rewritten for create/edit/delete; medications + appointments + health stats all bind to the active profile and filter by ?profile_id= server-side.
  • All 11 frontend encrypt/decrypt sites switched from the account DEK to the active profile's DEK.

Per the ADR: DB wipe, no migration (no real user data).

Verification

  • Backend: cargo build + clippy --all-targets --all-features + fmt --check clean. Tests compile. New profile_tests.rs covers multi-profile CRUD, ownership isolation, and register-with-default-profile.
  • Frontend: tsc clean, 30/30 tests pass (2 new per-profile DEK isolation tests).
  • ⚠️ Backend integration tests did not run in my sandbox — no local MongoDB, no Docker daemon. They were silently skipping. CI has Mongo (PR #8 fixed the port collision), so they'll run for real on this PR; I'll fix anything that fails.

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_shares collection, 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 A2 landed (PR #9, not yet merged) [`feat/3-phase-a2-per-profile-deks`](../pulls/9) implements the 3-tier key model from the ADR §1: - **Per-profile DEKs**: each profile (a person or pet — a "subject of care") gets its own random AES-256-GCM DEK. All health data is now encrypted under the active profile's DEK. The account DEK wraps each profile DEK; the server stores only opaque wrapped blobs. - **Multi-profile**: one account owns many profiles. New `GET/POST /api/profiles`, `GET/PUT/DELETE /api/profiles/:id` (all ownership-scoped). `/api/profiles/me` removed. Profile model gains `owner_account_id`, `kind` (human/pet), `relationship`, `wrapped_profile_dek`. - **Profile switcher UI** in the Dashboard AppBar; ProfileEditor rewritten for create/edit/delete; medications + appointments + health stats all bind to the active profile and filter by `?profile_id=` server-side. - All 11 frontend encrypt/decrypt sites switched from the account DEK to the active profile's DEK. Per the ADR: DB wipe, no migration (no real user data). ## Verification - Backend: `cargo build` + `clippy --all-targets --all-features` + `fmt --check` clean. Tests compile. New `profile_tests.rs` covers multi-profile CRUD, ownership isolation, and register-with-default-profile. - Frontend: `tsc` clean, 30/30 tests pass (2 new per-profile DEK isolation tests). - **⚠️ Backend integration tests did not run in my sandbox** — no local MongoDB, no Docker daemon. They were silently skipping. CI has Mongo (PR #8 fixed the port collision), so they'll run for real on this PR; I'll fix anything that fails. ## 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_shares` collection, `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.
Author
Owner

Phase B landed (PR #11, not yet merged)

feat/3-phase-b-profile-sharing implements 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 when profile_id is specified, then query as the resolved owner. Data repos stay ownership-scoped; shares live entirely in the handler layer.

What's in the PR

  • Backend: ProfileShare model + 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.
  • Removed the legacy Share system (ADR Open Q5): the old ACL-style shares + permissions + the dead permission middleware. ~7 files deleted; the new profile_shares collection replaces them.
  • Frontend: ECDH envelope helpers; loadSharedWithMe + shareProfile/revokeShare in the store; ProfileSwitcher shows shared profiles with a chip; new ProfileSharing UI 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_appointment previously 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

  • Phase C: hard revoke / re-key (rotate the profile DEK so a revoked recipient's cached key stops working).
  • Phase D: graduation / ownership transfer.
  • Plus the write-path ownership hole, and #5 (dead AccessClaims).

PR #11 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-sharing`](../pulls/11) implements 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 when `profile_id` is specified, then query as the resolved owner. Data repos stay ownership-scoped; shares live entirely in the handler layer. ### What's in the PR - Backend: `ProfileShare` model + 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. - **Removed the legacy `Share` system** (ADR Open Q5): the old ACL-style shares + permissions + the dead permission middleware. ~7 files deleted; the new `profile_shares` collection replaces them. - Frontend: ECDH envelope helpers; `loadSharedWithMe` + `shareProfile`/`revokeShare` in the store; ProfileSwitcher shows shared profiles with a chip; new `ProfileSharing` UI 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_appointment` previously 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 - **Phase C**: hard revoke / re-key (rotate the profile DEK so a revoked recipient's cached key stops working). - **Phase D**: graduation / ownership transfer. - Plus the write-path ownership hole, and #5 (dead `AccessClaims`). PR #11 does not close this issue — leaving #3 open as the tracking anchor.
Author
Owner

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

  • ProfileShare model + 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 whose profile_id doesn't map to a Profile document still read for their owner).
  • Removed the legacy ACL Share system + dead permission middleware.
  • Frontend ECDH envelope helpers, recipient-side profile loading, owner share/revoke UI, shared-profile indicator in the switcher.
  • share_tests.rs covering 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:

  1. Test-side: GET query-string encoding (send_json doesn't build query strings) + a wrong assumption about the medication-create HTTP code.
  2. Handler-side: the share-gate was too strict — it admitted owners only via the profiles collection, 404-ing any item whose profile_id had 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_appointment previously 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

  • Phase C: hard revoke / re-key (rotate the profile DEK so a revoked recipient's cached key stops working; the ADR's answer to the hard-revocation requirement).
  • Phase D: graduation / ownership transfer.
  • Plus #12 (write-path ownership) and #5 (dead AccessClaims).

Keeping this issue open as the tracking anchor for C and D.

## ✅ 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 - `ProfileShare` model + 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 whose `profile_id` doesn't map to a Profile document still read for their owner). - Removed the legacy ACL `Share` system + dead permission middleware. - Frontend ECDH envelope helpers, recipient-side profile loading, owner share/revoke UI, shared-profile indicator in the switcher. - `share_tests.rs` covering 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: 1. Test-side: GET query-string encoding (`send_json` doesn't build query strings) + a wrong assumption about the medication-create HTTP code. 2. **Handler-side**: the share-gate was too strict — it admitted owners only via the profiles collection, 404-ing any item whose `profile_id` had 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_appointment` previously 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 - **Phase C**: hard revoke / re-key (rotate the profile DEK so a revoked recipient's cached key stops working; the ADR's answer to the hard-revocation requirement). - **Phase D**: graduation / ownership transfer. - Plus #12 (write-path ownership) and #5 (dead `AccessClaims`). Keeping this issue open as the tracking anchor for C and D.
Author
Owner

Phase C landed (PR #15, not yet merged)

feat/3-phase-c-rekey implements 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

  • Backend: 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.rs covers rotation, hard-revoke-from-recipient-view, and the edge cases.
  • Frontend: 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). ProfileSharing gets a "Rotate encryption key" action with a strong confirmation dialog.
  • Best-effort + resumable retry (per design decision); no server-side write lock.

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

  • Phase D: graduation / ownership transfer (the owner-share swap that lets a child profile become an independent account that can revoke its parents). After that, all four phases of the ADR are complete.
  • Admin-initiated rekey (ADR §5c) is reserved for Phase D too (admin shares aren't creatable in the UI yet).

Keeping #3 open as the tracking anchor.

## Phase C landed (PR #15, not yet merged) [`feat/3-phase-c-rekey`](../pulls/15) implements 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 - Backend: `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.rs` covers rotation, hard-revoke-from-recipient-view, and the edge cases. - Frontend: `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). `ProfileSharing` gets a "Rotate encryption key" action with a strong confirmation dialog. - Best-effort + resumable retry (per design decision); no server-side write lock. ### 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 - **Phase D**: graduation / ownership transfer (the owner-share swap that lets a child profile become an independent account that can revoke its parents). After that, all four phases of the ADR are complete. - Admin-initiated rekey (ADR §5c) is reserved for Phase D too (admin shares aren't creatable in the UI yet). Keeping #3 open as the tracking anchor.
Author
Owner

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_profile echoed the pre-image of the wrapped DEK in its response (MongoDB's find_one_and_update returns 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 the actions/tasks endpoint.

ADR status — one phase left

  • A1 (keypair), A2 (per-profile DEKs), B (sharing), C (hard revoke) — all on main
  • Phase D: graduation / ownership transfer — the owner-share swap that lets a child profile become an independent account that can revoke its parents. Reuses the Phase B/C machinery.

Plus 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.

## ✅ 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_profile` echoed the **pre-image** of the wrapped DEK in its response (MongoDB's `find_one_and_update` returns 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 the `actions/tasks` endpoint. ## ADR status — one phase left - ✅ A1 (keypair), A2 (per-profile DEKs), B (sharing), **C (hard revoke)** — all on `main` - ⏳ **Phase D**: graduation / ownership transfer — the owner-share swap that lets a child profile become an independent account that can revoke its parents. Reuses the Phase B/C machinery. Plus 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.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: alvaro/normogen#3
No description provided.