feat: profile sharing via X25519 envelope (Phase B, #3) #11
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/3-phase-b-profile-sharing"
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?
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)
GET /api/profiles/shared-with-meendpoint (the two wrap forms are structurally different)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 whenprofile_idis 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[checksactive+expires_at],delete,upsert). Indexed on(profileId, recipientUserId)andrecipientUserId.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 theauthorize_profile_readgate.list_medications/get_medication/list_appointments/get_appointment/list_health_stats/get_health_stat.Sharesystem (ADR Open Q5, decided):models/{share,permission}.rs,handlers/{shares,permissions}.rs,middleware/permission.rs(already dead), thesharescollection field + methods inmongodb_impl.rs, thesharesindex, and the 5/api/shares+/api/permissions/checkroutes.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 withis_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.Verification
cargo build+clippy --all-targets --all-features -D warnings+fmt --checkall clean. Tests compile.tsc --noEmitclean; 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.rswill 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 tookExtension(_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)
AccessClaimscleanup).Refs #3.
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.Test failures diagnosed + fixed (commit
e28135b)Three
share_testsfailed in CI (run 137). All were test-side bugs, not handler bugs:public_key_lookup_returns_recipient_identity_key— passed the email as a JSON body on a GET, butsend_jsonbuilds 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@).public_key_lookup_404s_for_unknown_or_keyless_user— same root cause, same fix.owner_shares_recipient_reads_owner_revokes— asserted the recipient's write attempt would return 201 or 403. Actually returns 200:create_medicationreturnsOk(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'suser_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.
Second CI failure diagnosed + fixed (commit
d0efecf)After the test-only fix,
zk_integration_tests::medication_stored_as_ciphertext_not_plaintextfailed: it creates a medication withprofile_id="default"(no backing Profile document) and the newget_medicationgate returned 404.Root cause
My share-gate was too strict — it admitted the owner only via the profiles collection. Any data item whose
profile_iddidn't map to a real Profile document got 404'd on read, even for the item's actual owner.profile_idis 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 whoseprofile_idisn'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_testsstill holds.Handler-only change in 3 files (medications, appointments, health_stats). Build/clippy/fmt clean. Pushed; CI re-runs.