normogen/backend/src/models/profile_share.rs
goose bf5aeedbc2
Some checks failed
Lint and Build / format (pull_request) Successful in 40s
Lint and Build / clippy (pull_request) Successful in 1m56s
Lint and Build / build (pull_request) Successful in 3m47s
Lint and Build / test (pull_request) Failing after 3m51s
feat: hard revoke / re-key (Phase C, #3)
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.
2026-07-19 15:15:39 -03:00

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(())
}
}