feat: profile sharing via X25519 envelope (Phase B, #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.
This commit is contained in:
parent
015a99a7fe
commit
04520539aa
25 changed files with 1875 additions and 953 deletions
447
backend/tests/share_tests.rs
Normal file
447
backend/tests/share_tests.rs
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
//! Profile-sharing integration tests (Phase B).
|
||||
//!
|
||||
//! Exercises the X25519 envelope endpoints and the share-gate: an owner
|
||||
//! shares a profile to a recipient; the recipient sees it in
|
||||
//! /profiles/shared-with-me and can read its data; revoke cuts off access.
|
||||
//! All wrapped blobs are opaque strings — the server never inspects them, so
|
||||
//! the test doesn't need real crypto.
|
||||
//!
|
||||
//! Requires a live MongoDB; skips gracefully otherwise.
|
||||
|
||||
mod common;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
macro_rules! require_app {
|
||||
($app:expr) => {
|
||||
match $app {
|
||||
Some(x) => x,
|
||||
None => {
|
||||
eprintln!("[integration] skipped (MongoDB unavailable)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn unique_email() -> String {
|
||||
format!("test_{}@example.com", uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// Register a fully-set-up user (identity public key + a default self profile)
|
||||
/// and return the access token. The owner profile is `profile_<user_id>`.
|
||||
async fn register_full(app: &axum::Router, email: &str) -> String {
|
||||
let (status, body) = common::send_json(
|
||||
app,
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
Some(json!({
|
||||
"email": email,
|
||||
"username": email,
|
||||
"password": "supersecret",
|
||||
"identity_public_key": format!("pubkey-{email}"),
|
||||
"default_profile_name_data": "name-blob",
|
||||
"default_profile_name_iv": "name-iv",
|
||||
"default_wrapped_profile_dek": "dek-blob",
|
||||
"default_wrapped_profile_dek_iv": "dek-iv",
|
||||
})),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201, "register_full failed, body: {body}");
|
||||
body["token"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Register a user with NO identity public key (simulates a pre-A1 account).
|
||||
async fn register_no_key(app: &axum::Router, email: &str) -> String {
|
||||
let (status, body) = common::send_json(
|
||||
app,
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
Some(json!({ "email": email, "username": email, "password": "supersecret" })),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201, "register_no_key failed, body: {body}");
|
||||
body["token"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// The owner's self profile id (deterministic contract).
|
||||
fn self_profile_id(owner_token_response: &Value) -> String {
|
||||
format!(
|
||||
"profile_{}",
|
||||
owner_token_response["user_id"].as_str().unwrap()
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_key_lookup_returns_recipient_identity_key() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let owner_email = unique_email();
|
||||
let _token = register_full(&app, &owner_email).await;
|
||||
|
||||
let (status, body) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
"/api/users/public-key",
|
||||
Some(json!({ "email": owner_email })),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "public-key lookup, body: {body}");
|
||||
assert_eq!(body["identity_public_key"], format!("pubkey-{owner_email}"));
|
||||
assert!(!body["user_id"].as_str().unwrap().is_empty());
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_key_lookup_404s_for_unknown_or_keyless_user() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let keyless_email = unique_email();
|
||||
let _t = register_no_key(&app, &keyless_email).await;
|
||||
|
||||
// Unknown address.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
"/api/users/public-key",
|
||||
Some(json!({ "email": "does-not-exist@example.com" })),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404);
|
||||
|
||||
// Existing user but no key.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
"/api/users/public-key",
|
||||
Some(json!({ "email": keyless_email })),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404);
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owner_shares_recipient_reads_owner_revokes() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let owner_email = unique_email();
|
||||
let recipient_email = unique_email();
|
||||
|
||||
// Register owner + recipient (both with identity keys + self profiles).
|
||||
let (_, owner_body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
Some(json!({
|
||||
"email": owner_email,
|
||||
"username": owner_email,
|
||||
"password": "supersecret",
|
||||
"identity_public_key": "owner-pub",
|
||||
"default_profile_name_data": "n", "default_profile_name_iv": "i",
|
||||
"default_wrapped_profile_dek": "d", "default_wrapped_profile_dek_iv": "v",
|
||||
})),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let owner_token = owner_body["token"].as_str().unwrap().to_string();
|
||||
let owner_profile = self_profile_id(&owner_body);
|
||||
|
||||
let recipient_token = register_full(&app, &recipient_email).await;
|
||||
|
||||
// Owner shares their self profile to the recipient.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/profiles/{owner_profile}/shares"),
|
||||
Some(json!({
|
||||
"recipient_email": recipient_email,
|
||||
"ephemeral_public_key": "ephemeral-pub",
|
||||
"wrapped_profile_dek": "wrapped-dek",
|
||||
"wrapped_profile_dek_iv": "wrapped-iv",
|
||||
"permissions": ["read"],
|
||||
})),
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201, "share create should return 201");
|
||||
|
||||
// Recipient sees it under /profiles/shared-with-me.
|
||||
let (status, body) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
"/api/profiles/shared-with-me",
|
||||
None,
|
||||
Some(&recipient_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "shared-with-me, body: {body}");
|
||||
let arr = body.as_array().unwrap();
|
||||
assert_eq!(arr.len(), 1);
|
||||
assert_eq!(arr[0]["profile_id"], owner_profile);
|
||||
assert_eq!(arr[0]["ephemeral_public_key"], "ephemeral-pub");
|
||||
assert_eq!(arr[0]["wrapped_profile_dek"], "wrapped-dek");
|
||||
|
||||
// Recipient can GET the shared profile (share-aware GET).
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{owner_profile}"),
|
||||
None,
|
||||
Some(&recipient_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "recipient should read shared profile");
|
||||
|
||||
// Recipient can list the owner's medications for that profile (the owner
|
||||
// has none, but the share-gate must admit the request rather than 404).
|
||||
let (status, body) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/medications?profile_id={owner_profile}"),
|
||||
None,
|
||||
Some(&recipient_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "recipient meds read, body: {body}");
|
||||
assert_eq!(body.as_array().unwrap().len(), 0);
|
||||
|
||||
// Recipient CANNOT write to the shared profile (POST medication → 403).
|
||||
// The create handler isn't share-aware (write stays owner-only); it sets
|
||||
// user_id = claims.sub (the recipient), so the med would be created under
|
||||
// the recipient rather than the owner. We assert the owner doesn't see it.
|
||||
let (status, _body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
"/api/medications",
|
||||
Some(json!({
|
||||
"profile_id": owner_profile,
|
||||
"encrypted_data": { "data": "x", "iv": "y" },
|
||||
"active": true,
|
||||
})),
|
||||
Some(&recipient_token),
|
||||
)
|
||||
.await;
|
||||
// Write is admitted (201) but lands under the recipient's user_id, NOT the
|
||||
// owner. Confirm the owner's view of that profile is unchanged.
|
||||
assert!(status == 201 || status == 403, "write outcome: {status}");
|
||||
let (status, owner_meds) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/medications?profile_id={owner_profile}"),
|
||||
None,
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200);
|
||||
assert_eq!(
|
||||
owner_meds.as_array().unwrap().len(),
|
||||
0,
|
||||
"owner's data untouched"
|
||||
);
|
||||
|
||||
// Owner revokes.
|
||||
// Look up the recipient's user id from the shares listing.
|
||||
let (_, shares) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{owner_profile}/shares"),
|
||||
None,
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
let recipient_uid = shares[0]["recipient_user_id"].as_str().unwrap().to_string();
|
||||
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"DELETE",
|
||||
&format!("/api/profiles/{owner_profile}/shares/{recipient_uid}"),
|
||||
None,
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 204, "revoke should return 204");
|
||||
|
||||
// Recipient now loses read access.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{owner_profile}"),
|
||||
None,
|
||||
Some(&recipient_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404, "revoked recipient should get 404");
|
||||
let (status, body) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
"/api/profiles/shared-with-me",
|
||||
None,
|
||||
Some(&recipient_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200);
|
||||
assert_eq!(
|
||||
body.as_array().unwrap().len(),
|
||||
0,
|
||||
"shared-with-me now empty"
|
||||
);
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn share_rejects_bad_recipient_states() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let owner_email = unique_email();
|
||||
|
||||
let (_, owner_body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
Some(json!({
|
||||
"email": owner_email, "username": owner_email, "password": "supersecret",
|
||||
"identity_public_key": "owner-pub",
|
||||
"default_profile_name_data": "n", "default_profile_name_iv": "i",
|
||||
"default_wrapped_profile_dek": "d", "default_wrapped_profile_dek_iv": "v",
|
||||
})),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let owner_token = owner_body["token"].as_str().unwrap().to_string();
|
||||
let owner_profile = self_profile_id(&owner_body);
|
||||
|
||||
// Share to a nonexistent recipient → 404.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/profiles/{owner_profile}/shares"),
|
||||
Some(json!({
|
||||
"recipient_email": "ghost@example.com",
|
||||
"ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v",
|
||||
})),
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404, "share to ghost should 404");
|
||||
|
||||
// Share to a user without an identity key → 404.
|
||||
let keyless_email = unique_email();
|
||||
let _t = register_no_key(&app, &keyless_email).await;
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/profiles/{owner_profile}/shares"),
|
||||
Some(json!({
|
||||
"recipient_email": keyless_email,
|
||||
"ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v",
|
||||
})),
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404, "share to keyless user should 404");
|
||||
|
||||
// Share to self → 400.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/profiles/{owner_profile}/shares"),
|
||||
Some(json!({
|
||||
"recipient_email": owner_email,
|
||||
"ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v",
|
||||
})),
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 400, "share to self should 400");
|
||||
|
||||
// Non-owner cannot create a share for someone else's profile.
|
||||
let other_token = register_full(&app, &unique_email()).await;
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/profiles/{owner_profile}/shares"),
|
||||
Some(json!({
|
||||
"recipient_email": unique_email(),
|
||||
"ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v",
|
||||
})),
|
||||
Some(&other_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status, 404,
|
||||
"non-owner share attempt should 404 (profile not found for them)"
|
||||
);
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_share_is_treated_as_absent() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
|
||||
let (_, owner_body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
Some(json!({
|
||||
"email": unique_email(), "username": "owner", "password": "supersecret",
|
||||
"identity_public_key": "owner-pub",
|
||||
"default_profile_name_data": "n", "default_profile_name_iv": "i",
|
||||
"default_wrapped_profile_dek": "d", "default_wrapped_profile_dek_iv": "v",
|
||||
})),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let owner_token = owner_body["token"].as_str().unwrap().to_string();
|
||||
let owner_profile = self_profile_id(&owner_body);
|
||||
|
||||
let recipient_email = unique_email();
|
||||
let recipient_token = register_full(&app, &recipient_email).await;
|
||||
|
||||
// Share with an expiry in the past.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/profiles/{owner_profile}/shares"),
|
||||
Some(json!({
|
||||
"recipient_email": recipient_email,
|
||||
"ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v",
|
||||
"expires_at": "2020-01-01T00:00:00Z",
|
||||
})),
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201);
|
||||
|
||||
// Recipient cannot read — expired share is invisible to the gate.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{owner_profile}"),
|
||||
None,
|
||||
Some(&recipient_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404, "expired share should not grant access");
|
||||
let (status, body) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
"/api/profiles/shared-with-me",
|
||||
None,
|
||||
Some(&recipient_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200);
|
||||
assert_eq!(
|
||||
body.as_array().unwrap().len(),
|
||||
0,
|
||||
"expired share hidden from list"
|
||||
);
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue