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

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:
goose 2026-07-19 07:21:11 -03:00
parent 015a99a7fe
commit 04520539aa
25 changed files with 1875 additions and 953 deletions

View file

@ -0,0 +1,179 @@
use futures::stream::StreamExt;
use mongodb::{
bson::{doc, oid::ObjectId, DateTime},
Collection,
};
use serde::{Deserialize, Serialize};
/// A profile shared from one account (owner) to another (recipient).
///
/// Zero-knowledge envelope (Phase B, see `docs/adr/multi-person-sharing.md`):
/// the owner wraps the profile DEK to the recipient's X25519 identity public
/// key via ECDH, using a fresh ephemeral keypair per share. The server stores
/// only opaque ciphertext + public keys and cannot read the profile DEK.
///
/// `permissions` reserves `read` (Phase B), `write`, and `admin` for later
/// phases. Phase B only grants read access.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileShare {
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
pub id: Option<ObjectId>,
#[serde(rename = "profileId")]
pub profile_id: String,
/// Owner's account id (the profile's owner). Denormalized from the
/// profile for query efficiency without a join.
#[serde(rename = "ownerUserId")]
pub owner_user_id: String,
/// Recipient's account id.
#[serde(rename = "recipientUserId")]
pub recipient_user_id: String,
/// Ephemeral X25519 public key (base64 raw) generated for this share. The
/// recipient combines it with their identity private key to ECDH-derive
/// the wrapping key. Plaintext — public keys are not secret.
#[serde(rename = "ephemeralPublicKey")]
pub ephemeral_public_key: String,
/// Profile DEK wrapped (AES-256-GCM) under the ECDH-derived key. Opaque.
#[serde(rename = "wrappedProfileDek")]
pub wrapped_profile_dek: String,
#[serde(rename = "wrappedProfileDekIv")]
pub wrapped_profile_dek_iv: String,
/// Reserved for later phases. Phase B writes `["read"]`.
#[serde(rename = "permissions", default = "default_read")]
pub permissions: Vec<String>,
/// Optional expiry; if set and past, `find_active` treats the share as gone.
#[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime>,
#[serde(rename = "createdAt")]
pub created_at: DateTime,
/// Supports a future "disable without delete" path. Phase B soft-revoke
/// hard-deletes the doc, but the field is kept for forward-compat.
#[serde(rename = "active", default = "default_true")]
pub active: bool,
}
fn default_read() -> Vec<String> {
vec!["read".to_string()]
}
fn default_true() -> bool {
true
}
pub struct ProfileShareRepository {
collection: Collection<ProfileShare>,
}
impl ProfileShareRepository {
pub fn new(collection: Collection<ProfileShare>) -> Self {
Self { collection }
}
pub async fn create(&self, share: &ProfileShare) -> mongodb::error::Result<()> {
self.collection.insert_one(share, None).await?;
Ok(())
}
/// All shares where the given account is the recipient (for the
/// `/profiles/shared-with-me` endpoint).
pub async fn find_for_recipient(
&self,
recipient_user_id: &str,
) -> mongodb::error::Result<Vec<ProfileShare>> {
let mut cursor = self
.collection
.find(doc! { "recipientUserId": recipient_user_id }, None)
.await?;
let mut out = Vec::new();
while let Some(s) = cursor.next().await {
out.push(s?);
}
Ok(out)
}
/// All shares for a given profile (owner listing who they've shared with).
pub async fn find_for_profile(
&self,
profile_id: &str,
) -> mongodb::error::Result<Vec<ProfileShare>> {
let mut cursor = self
.collection
.find(doc! { "profileId": profile_id }, None)
.await?;
let mut out = Vec::new();
while let Some(s) = cursor.next().await {
out.push(s?);
}
Ok(out)
}
/// A specific (profile, recipient) share regardless of active/expiry state.
pub async fn find(
&self,
profile_id: &str,
recipient_user_id: &str,
) -> mongodb::error::Result<Option<ProfileShare>> {
self.collection
.find_one(
doc! { "profileId": profile_id, "recipientUserId": recipient_user_id },
None,
)
.await
}
/// A specific (profile, recipient) share, only if currently usable:
/// `active == true` and not past `expires_at`. Used by the share-gate.
pub async fn find_active(
&self,
profile_id: &str,
recipient_user_id: &str,
) -> mongodb::error::Result<Option<ProfileShare>> {
let now = DateTime::now();
// active==true AND (expiresAt missing OR expiresAt > now)
let filter = doc! {
"profileId": profile_id,
"recipientUserId": recipient_user_id,
"active": true,
"$or": [
{ "expiresAt": { "$exists": false } },
{ "expiresAt": null },
{ "expiresAt": { "$gt": now } },
],
};
self.collection.find_one(filter, None).await
}
/// Hard-delete (soft-revoke) the (profile, recipient) share. Returns true
/// if a doc was deleted.
pub async fn delete(
&self,
profile_id: &str,
recipient_user_id: &str,
) -> mongodb::error::Result<bool> {
let res = self
.collection
.delete_one(
doc! { "profileId": profile_id, "recipientUserId": recipient_user_id },
None,
)
.await?;
Ok(res.deleted_count > 0)
}
/// Upsert: replace any existing (profile, recipient) share with the given
/// one. Used when re-sharing (e.g. rotating the ephemeral key). Deletes
/// existing rows for the pair first, then inserts.
pub async fn upsert(&self, share: &ProfileShare) -> mongodb::error::Result<()> {
let _ = self
.collection
.delete_one(
doc! {
"profileId": &share.profile_id,
"recipientUserId": &share.recipient_user_id,
},
None,
)
.await?;
self.collection.insert_one(share, None).await?;
Ok(())
}
}