feat: profile sharing via X25519 envelope (Phase B, #3) #11

Merged
alvaro merged 3 commits from feat/3-phase-b-profile-sharing into main 2026-07-19 14:51:58 +00:00
Owner

Summary

Phase B of the multi-person sharing ADR. An owner can now share a profile with 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 — sharing uses an X25519 envelope: the owner wraps the profile DEK to the recipient's identity public key via ECDH (fresh ephemeral key per share); the recipient unwraps it with their identity private key.

Builds on Phase A1 (account keypair) + A2 (per-profile DEKs), both merged.

Decisions (from clarification)

  • Full read access to shared data (not just metadata)
  • Separate GET /api/profiles/shared-with-me endpoint (the two wrap forms are structurally different)
  • Soft revoke = hard-delete the share record (cached-DEK caveat accepted; hard revoke/re-key is Phase C)

Architecture: the share-gate

The hard problem was that all data handlers hardcoded userId == claims.sub. A recipient has a different userId. The fix is a share-authorization gate in the handler layer: authorize_profile_read(state, claims, profile_id) 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. The data repos stay ownership-scoped — shares live entirely in the handler layer.

Backend

  • models/profile_share.rs (new): ProfileShare + repository (find_for_recipient, find_for_profile, find_active [checks active + expires_at], delete, upsert). Indexed on (profileId, recipientUserId) and recipientUserId.
  • handlers/profile_share.rs (new): POST/GET /profiles/:id/shares, DELETE /profiles/:id/shares/:recipient, GET /profiles/shared-with-me, GET /users/public-key (public — public keys aren't secret), and the authorize_profile_read gate.
  • Share-gate wired into list_medications / get_medication / list_appointments / get_appointment / list_health_stats / get_health_stat.
  • Removed the legacy Share system (ADR Open Q5, decided): models/{share,permission}.rs, handlers/{shares,permissions}.rs, middleware/permission.rs (already dead), the shares collection field + methods in mongodb_impl.rs, the shares index, and the 5 /api/shares + /api/permissions/check routes.
  • share_tests.rs (new): full owner→recipient→revoke flow, ownership isolation, share-to-self/nonexistent/keyless rejections, expired share treated as absent.

Frontend

  • crypto/keys.ts: wrapProfileDekToRecipient / unwrapProfileDekFromShare — the ECDH envelope (fresh ephemeral X25519 key per share; forward secrecy within a share's lifetime).
  • useProfileStore: loadSharedWithMe (unwrap each share's DEK with the identity private key), shareProfile, revokeShare, loadProfileShares. Shared profiles merge into the list with is_shared=true.
  • ProfileSwitcher: shows shared profiles with a "shared" chip.
  • ProfileSharing (new) + ProfileEditor: owner UI to add a recipient by email + revoke; shared profiles render read-only with the owner shown.
  • ECDH round-trip test: owner wraps → recipient unwraps → decrypt works; a stranger's key can't unwrap.

Verification

  • Backend: cargo build + clippy --all-targets --all-features -D warnings + fmt --check all clean. Tests compile.
  • Frontend: tsc --noEmit clean; 31/31 tests pass.

⚠️ Backend integration tests not run locally

My sandbox has no local MongoDB and no Docker daemon, so I couldn't execute the backend integration tests here — only compile/clippy/fmt them. CI has Mongo (PR #8 fixed the port collision), so share_tests.rs will run for real on this PR. I'll watch CI and fix any failures immediately.

Notable side effect

While wiring the gate into get_medication/get_appointment, I noticed those handlers previously took Extension(_claims) (unused) — i.e. any authenticated user could read/update/delete any medication/appointment by id, with no ownership check. The gate now enforces ownership-or-share on the read path. The write paths (update_*/delete_*) still don't check ownership — that's a pre-existing hole, out of scope here, but worth flagging for a follow-up.

Out of scope (later phases)

  • Write access for recipients (Phase C or later).
  • Hard revoke / re-key (Phase C).
  • Graduation / ownership transfer (Phase D).
  • Pre-existing write-path ownership holes on meds/appointments.
  • Issue #5 (dead AccessClaims cleanup).

Refs #3.

## Summary Phase B of the multi-person sharing ADR. An owner can now share a profile with 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 — sharing uses an **X25519 envelope**: the owner wraps the profile DEK to the recipient's identity public key via ECDH (fresh ephemeral key per share); the recipient unwraps it with their identity private key. Builds on Phase A1 (account keypair) + A2 (per-profile DEKs), both merged. ## Decisions (from clarification) - Full read access to shared data (not just metadata) - Separate `GET /api/profiles/shared-with-me` endpoint (the two wrap forms are structurally different) - Soft revoke = hard-delete the share record (cached-DEK caveat accepted; hard revoke/re-key is Phase C) ## Architecture: the share-gate The hard problem was that all data handlers hardcoded `userId == claims.sub`. A recipient has a different userId. The fix is a **share-authorization gate** in the handler layer: `authorize_profile_read(state, claims, profile_id)` 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. The data repos stay ownership-scoped — shares live entirely in the handler layer. ## Backend - **`models/profile_share.rs` (new)**: `ProfileShare` + repository (`find_for_recipient`, `find_for_profile`, `find_active` [checks `active` + `expires_at`], `delete`, `upsert`). Indexed on `(profileId, recipientUserId)` and `recipientUserId`. - **`handlers/profile_share.rs` (new)**: `POST/GET /profiles/:id/shares`, `DELETE /profiles/:id/shares/:recipient`, `GET /profiles/shared-with-me`, `GET /users/public-key` (public — public keys aren't secret), and the `authorize_profile_read` gate. - **Share-gate wired into** `list_medications` / `get_medication` / `list_appointments` / `get_appointment` / `list_health_stats` / `get_health_stat`. - **Removed the legacy `Share` system** (ADR Open Q5, decided): `models/{share,permission}.rs`, `handlers/{shares,permissions}.rs`, `middleware/permission.rs` (already dead), the `shares` collection field + methods in `mongodb_impl.rs`, the `shares` index, and the 5 `/api/shares` + `/api/permissions/check` routes. - **`share_tests.rs` (new)**: full owner→recipient→revoke flow, ownership isolation, share-to-self/nonexistent/keyless rejections, expired share treated as absent. ## Frontend - **`crypto/keys.ts`**: `wrapProfileDekToRecipient` / `unwrapProfileDekFromShare` — the ECDH envelope (fresh ephemeral X25519 key per share; forward secrecy within a share's lifetime). - **`useProfileStore`**: `loadSharedWithMe` (unwrap each share's DEK with the identity private key), `shareProfile`, `revokeShare`, `loadProfileShares`. Shared profiles merge into the list with `is_shared=true`. - **`ProfileSwitcher`**: shows shared profiles with a "shared" chip. - **`ProfileSharing` (new) + `ProfileEditor`**: owner UI to add a recipient by email + revoke; shared profiles render read-only with the owner shown. - **ECDH round-trip test**: owner wraps → recipient unwraps → decrypt works; a stranger's key can't unwrap. ## Verification - Backend: `cargo build` + `clippy --all-targets --all-features -D warnings` + `fmt --check` all clean. Tests compile. - Frontend: `tsc --noEmit` clean; **31/31 tests pass**. ## ⚠️ Backend integration tests not run locally My sandbox has no local MongoDB and no Docker daemon, so I couldn't execute the backend integration tests here — only compile/clippy/fmt them. **CI has Mongo** (PR #8 fixed the port collision), so `share_tests.rs` will run for real on this PR. I'll watch CI and fix any failures immediately. ## Notable side effect While wiring the gate into `get_medication`/`get_appointment`, I noticed those handlers previously took `Extension(_claims)` (unused) — i.e. **any authenticated user could read/update/delete any medication/appointment by id, with no ownership check**. The gate now enforces ownership-or-share on the read path. The write paths (`update_*`/`delete_*`) still don't check ownership — that's a pre-existing hole, out of scope here, but worth flagging for a follow-up. ## Out of scope (later phases) - Write access for recipients (Phase C or later). - Hard revoke / re-key (Phase C). - Graduation / ownership transfer (Phase D). - Pre-existing write-path ownership holes on meds/appointments. - Issue #5 (dead `AccessClaims` cleanup). Refs #3.
alvaro added 1 commit 2026-07-19 10:21:45 +00:00
feat: profile sharing via X25519 envelope (Phase B, #3)
Some checks failed
Lint and Build / format (pull_request) Successful in 35s
Lint and Build / clippy (pull_request) Successful in 1m41s
Lint and Build / build (pull_request) Successful in 3m51s
Lint and Build / test (pull_request) Failing after 3m41s
04520539aa
An owner can now share a profile with 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:
sharing uses an X25519 envelope — the owner wraps the profile DEK to
the recipient's identity public key via ECDH (fresh ephemeral key per
share), the recipient unwraps it with their identity private key.

Backend:
- models/profile_share.rs: ProfileShare + ProfileShareRepository
  (find_for_recipient, find_for_profile, find_active [checks active +
  expiry], delete, upsert). Indexed on (profileId, recipientUserId) and
  recipientUserId.
- handlers/profile_share.rs: POST/GET /profiles/:id/shares,
  DELETE /profiles/:id/shares/:recipient, GET /profiles/shared-with-me,
  GET /users/public-key (public), and authorize_profile_read — the
  share-gate that admits owner OR active-share recipient.
- The share-gate is wired into list/get for medications, appointments,
  and health stats: when profile_id is specified, resolve the owner via
  the gate and query as them. Data repos stay ownership-scoped.
- Removed the legacy Share system (ADR Open Q5): models/{share,
  permission}.rs, handlers/{shares,permissions}.rs, middleware/
  permission.rs (dead), the shares collection field + methods in
  mongodb_impl.rs, the shares index, and the 5 /api/shares +
  /api/permissions routes.
- share_tests.rs: full owner→recipient→revoke flow, ownership
  isolation, share-to-self/nonexistent/keyless rejections, expired
  share treated as absent.

Frontend:
- crypto/keys.ts: wrapProfileDekToRecipient /
  unwrapProfileDekFromShare (ECDH envelope, ephemeral key per share).
- useProfileStore: loadSharedWithMe (unwrap each share's DEK with the
  identity private key), shareProfile, revokeShare, loadProfileShares.
  Shared profiles merge into the list with is_shared=true.
- ProfileSwitcher: shows shared profiles with a 'shared' chip.
- ProfileSharing (new) + ProfileEditor: owner UI to add a recipient by
  email and revoke; shared profiles render read-only with owner info.
- ECDH round-trip test (owner wraps, recipient unwraps, stranger can't).

Verification: backend cargo build/clippy (-D warnings)/fmt clean, tests
compile (integration tests run in CI — Mongo is fixed there). Frontend
tsc clean, 31/31 tests pass.

⚠️ Backend integration tests not run locally (no Mongo/Docker in this
sandbox); CI will run them — I'll fix any failures immediately.

Phases C (hard revoke / re-key) and D (graduation) remain. Refs #3.
alvaro added 1 commit 2026-07-19 13:37:42 +00:00
test(share): fix query-string encoding + write-status assertion
Some checks failed
Lint and Build / format (pull_request) Successful in 36s
Lint and Build / clippy (pull_request) Successful in 1m39s
Lint and Build / build (pull_request) Successful in 3m44s
Lint and Build / test (pull_request) Failing after 3m43s
e28135bc08
Three share_tests were failing in CI:

1. public_key_lookup_returns_recipient_identity_key — passed the email
   as a JSON body on a GET, but send_json builds the URI verbatim and
   ignores the body for GET. The query string was never formed, so the
   handler got an empty email. Fix: put ?email=... in the URI directly
   (URL-encoding the '@').

2. public_key_lookup_404s_for_unknown_or_keyless_user — same root cause.

3. owner_shares_recipient_reads_owner_revokes — asserted the recipient's
   write would return 201 or 403, but create_medication returns 200 OK
   (Axum's default for Ok(Json(...))), and the write currently succeeds
   because the create handler doesn't check ownership (issue #12). The
   write is attributed to the recipient, not the owner, so the owner's
   data is still untouched — which is what the test really wants to
   prove. Accept 200 (current) or 403 (once #12 lands); documented the
   tie to #12.
Author
Owner

Test failures diagnosed + fixed (commit e28135b)

Three share_tests failed in CI (run 137). All were test-side bugs, not handler bugs:

  1. public_key_lookup_returns_recipient_identity_key — passed the email as a JSON body on a GET, but send_json builds the URI verbatim and ignores the body for GETs. The ?email=... query was never formed, so the handler got an empty email. Fix: put ?email=... in the URI directly (URL-encoding the @).
  2. public_key_lookup_404s_for_unknown_or_keyless_user — same root cause, same fix.
  3. owner_shares_recipient_reads_owner_revokes — asserted the recipient's write attempt would return 201 or 403. Actually returns 200: create_medication returns Ok(Json(...)) (Axum default = 200, not 201), and the write succeeds because the create handler doesn't check ownership (the issue #12 hole). What the test really proves — that the owner's data is untouched — still holds, because the write is attributed to the recipient's user_id, not the owner's. Accept 200 (current) or 403 (once #12 lands); the comment ties it to #12.

No handler code changed — only the tests. Pushed; CI will re-run.

## Test failures diagnosed + fixed (commit e28135b) Three `share_tests` failed in CI (run 137). All were test-side bugs, not handler bugs: 1. **`public_key_lookup_returns_recipient_identity_key`** — passed the email as a JSON body on a GET, but `send_json` builds the URI verbatim and ignores the body for GETs. The `?email=...` query was never formed, so the handler got an empty email. Fix: put `?email=...` in the URI directly (URL-encoding the `@`). 2. **`public_key_lookup_404s_for_unknown_or_keyless_user`** — same root cause, same fix. 3. **`owner_shares_recipient_reads_owner_revokes`** — asserted the recipient's write attempt would return 201 or 403. Actually returns **200**: `create_medication` returns `Ok(Json(...))` (Axum default = 200, not 201), and the write *succeeds* because the create handler doesn't check ownership (the issue #12 hole). What the test really proves — that the owner's data is untouched — still holds, because the write is attributed to the recipient's `user_id`, not the owner's. Accept 200 (current) or 403 (once #12 lands); the comment ties it to #12. No handler code changed — only the tests. Pushed; CI will re-run.
alvaro added 1 commit 2026-07-19 14:40:45 +00:00
fix(sharing): gate falls back to direct ownership before share check
All checks were successful
Lint and Build / format (pull_request) Successful in 36s
Lint and Build / clippy (pull_request) Successful in 1m43s
Lint and Build / build (pull_request) Successful in 3m50s
Lint and Build / test (pull_request) Successful in 3m41s
d0efecf38f
The share-gate I added in Phase B was too strict: it only admitted the
owner via the profiles collection, so any data item whose profile_id
didn't map to a real Profile document (legacy/test data, or a profile_id
the caller used at create time without a backing Profile) got 404'd on
read — even for the item's actual owner. CI caught this:
zk_integration_tests::medication_stored_as_ciphertext_not_plaintext
creates a medication with profile_id="default" (no Profile doc) and
the new get_medication gate returned 404.

Fix: in each list/get data handler, check direct ownership
(user_id == claims.sub, or has own data for the profile) FIRST, and
only fall through to the share-gate if the caller isn't the direct
owner. This preserves the original ownership model AND adds share-
recipient access, without breaking items whose profile_id isn't a real
Profile document.

The share-only path (recipient reading shared data they don't own)
still goes through the gate as before.
Author
Owner

Second CI failure diagnosed + fixed (commit d0efecf)

After the test-only fix, zk_integration_tests::medication_stored_as_ciphertext_not_plaintext failed: it creates a medication with profile_id="default" (no backing Profile document) and the new get_medication gate returned 404.

Root cause

My share-gate was too strict — it admitted the owner only via the profiles collection. Any data item whose profile_id didn't map to a real Profile document got 404'd on read, even for the item's actual owner. profile_id is a free-form string at create time (the create handler doesn't validate it against the profiles collection), so this broke legitimate data.

Fix

In each list/get data handler, check direct ownership first (user_id == claims.sub, or the caller has own data for the profile), and only fall through to the share-gate if the caller isn't the direct owner. This preserves the original ownership model AND adds share-recipient access, without breaking items whose profile_id isn't a real Profile document.

The share-only path (recipient reading shared data they don't own) still goes through the gate exactly as before — share_tests still holds.

Handler-only change in 3 files (medications, appointments, health_stats). Build/clippy/fmt clean. Pushed; CI re-runs.

## Second CI failure diagnosed + fixed (commit d0efecf) After the test-only fix, `zk_integration_tests::medication_stored_as_ciphertext_not_plaintext` failed: it creates a medication with `profile_id="default"` (no backing Profile document) and the new `get_medication` gate returned 404. ### Root cause My share-gate was too strict — it admitted the owner **only** via the profiles collection. Any data item whose `profile_id` didn't map to a real Profile document got 404'd on read, even for the item's actual owner. `profile_id` is a free-form string at create time (the create handler doesn't validate it against the profiles collection), so this broke legitimate data. ### Fix In each list/get data handler, check **direct ownership first** (`user_id == claims.sub`, or the caller has own data for the profile), and only fall through to the share-gate if the caller isn't the direct owner. This preserves the original ownership model AND adds share-recipient access, without breaking items whose `profile_id` isn't a real Profile document. The share-only path (recipient reading shared data they don't own) still goes through the gate exactly as before — `share_tests` still holds. Handler-only change in 3 files (medications, appointments, health_stats). Build/clippy/fmt clean. Pushed; CI re-runs.
alvaro merged commit d59487fa3b into main 2026-07-19 14:51:58 +00:00
Sign in to join this conversation.
No reviewers
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#11
No description provided.