An owner can now rotate a profile's DEK — generating a fresh key, re-encrypting all the profile's data under it (client-side), and re-wrapping to the owner share + kept recipients. A revoked recipient's cached old DEK stops working, closing the soft-revoke window the ADR requires for graduation / suspected compromise. The server stays a blind store: it sees opaque old→new ciphertext blobs flow through, never the DEK or plaintext. No backend crypto. Backend: - ProfileRepository::update_wrapped_dek — rotate the owner share (account-wrapped profile DEK), owner-scoped. - ProfileShareRepository::find_active_for_profile + delete_for_profile_excluding — list current recipients and hard-delete omitted ones. - POST /api/profiles/:id/rekey: validates each submitted envelope against existing active shares (no smuggling new recipients in via rekey), rotates the owner share, upserts recipient envelopes with fresh ephemeral ECDH keys, hard-deletes omitted recipients. - rekey_tests.rs: rotation, hard-revoke-from-recipient-view, no-share rejection, non-owner rejection, revoke-all. Frontend: - useProfileStore.rekeyProfile: fetches all profile data, re-encrypts each row under a new DEK (resume-safe — rows already on the new DEK are skipped), builds fresh ECDH envelopes per kept recipient, commits via POST /rekey, swaps the in-memory DEK. - ProfileSharing: 'Rotate encryption key (hard revoke)' action with a strong confirmation dialog explaining the cost and the resume-safe retry. Best-effort + resumable retry (per design decision); no server-side write lock. 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 in this sandbox); CI will run them — I'll fix any failures. Admin-initiated rekey (ADR §5c reserves the permission) is out of scope — handler is owner-only until admin shares are creatable in Phase D. Refs #3.
222 lines
7.8 KiB
Rust
222 lines
7.8 KiB
Rust
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
|
|
}
|
|
|
|
/// All currently-active shares for a profile (Phase C rekey needs the full
|
|
/// recipient list to validate submitted envelopes and hard-delete omitted
|
|
/// recipients). "Active" = `active==true` and not past `expires_at`.
|
|
pub async fn find_active_for_profile(
|
|
&self,
|
|
profile_id: &str,
|
|
) -> mongodb::error::Result<Vec<ProfileShare>> {
|
|
let now = DateTime::now();
|
|
let filter = doc! {
|
|
"profileId": profile_id,
|
|
"active": true,
|
|
"$or": [
|
|
{ "expiresAt": { "$exists": false } },
|
|
{ "expiresAt": null },
|
|
{ "expiresAt": { "$gt": now } },
|
|
],
|
|
};
|
|
let mut cursor = self.collection.find(filter, None).await?;
|
|
let mut out = Vec::new();
|
|
while let Some(s) = cursor.next().await {
|
|
out.push(s?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Hard-delete every share for the profile whose recipient is NOT in
|
|
/// `keep_recipient_ids` — the hard-revoke action in Phase C rekey. Recipients
|
|
/// omitted from the rekey call lose access at the key level (their cached
|
|
/// old DEK won't match the rotated one) AND the server stops serving them.
|
|
/// Returns the number deleted.
|
|
pub async fn delete_for_profile_excluding(
|
|
&self,
|
|
profile_id: &str,
|
|
keep_recipient_ids: &[String],
|
|
) -> mongodb::error::Result<u64> {
|
|
let mut filter = doc! { "profileId": profile_id };
|
|
if !keep_recipient_ids.is_empty() {
|
|
filter.insert("recipientUserId", doc! { "$nin": keep_recipient_ids });
|
|
}
|
|
let res = self.collection.delete_many(filter, None).await?;
|
|
Ok(res.deleted_count)
|
|
}
|
|
|
|
/// 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(())
|
|
}
|
|
}
|