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
179
backend/src/models/profile_share.rs
Normal file
179
backend/src/models/profile_share.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue