feat: hard revoke / re-key (Phase C, #3)
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

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.
This commit is contained in:
goose 2026-07-19 15:15:39 -03:00
parent 76fd5a8c26
commit bf5aeedbc2
10 changed files with 861 additions and 1 deletions

View file

@ -66,6 +66,8 @@ pub fn build_app(state: AppState) -> Router {
get(handlers::list_shares).post(handlers::create_share),
)
.route("/api/profiles/:id/shares/:recipient", delete(handlers::delete_share))
// Hard revoke / rotate the profile DEK (Phase C). Owner-only.
.route("/api/profiles/:id/rekey", post(handlers::rekey_profile))
// Session management (Phase 2.6)
.route("/api/sessions", get(handlers::get_sessions))
.route("/api/sessions/:id", delete(handlers::revoke_session))

View file

@ -26,7 +26,7 @@ pub use medications::{
pub use profile::{create_profile, delete_profile, get_profile, list_profiles, update_profile};
pub use profile_share::{
create_share, delete_share, get_profile_shared_aware, get_public_key, list_shared_with_me,
list_shares,
list_shares, rekey_profile,
};
pub use sessions::{get_sessions, revoke_all_sessions, revoke_session};
pub use users::{

View file

@ -511,3 +511,193 @@ pub async fn get_public_key(
identity_public_key: pk,
}))
}
// ---------------------------------------------------------------------------
// POST /api/profiles/:id/rekey — hard revoke / rotate the profile DEK (Phase C).
//
// The owner's client has already (a) generated a fresh profile DEK, (b)
// re-encrypted all the profile's data under it (client-side), and (c) wrapped
// the new DEK to its own account DEK + to each still-valid recipient's identity
// public key via fresh ECDH envelopes. This call commits the rotation: it
// stores the new owner-wrapped DEK, upserts each submitted recipient envelope,
// and hard-deletes any current recipient NOT in the submitted list (the
// hard-revoke). The server stores only opaque blobs + public keys.
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct RecipientEnvelope {
pub recipient_user_id: String,
pub ephemeral_public_key: String,
pub wrapped_profile_dek: String,
pub wrapped_profile_dek_iv: String,
}
#[derive(Debug, Deserialize)]
pub struct RekeyRequest {
/// The NEW profile DEK, wrapped under the owner's account DEK. Opaque.
pub new_wrapped_profile_dek: String,
pub new_wrapped_profile_dek_iv: String,
/// One fresh ECDH envelope per recipient the owner wants to KEEP. Any
/// current recipient not listed here is hard-revoked.
#[serde(default)]
pub recipient_envelopes: Vec<RecipientEnvelope>,
}
#[derive(Debug, Serialize)]
pub struct RekeyResponse {
pub profile_id: String,
pub wrapped_profile_dek: String,
pub wrapped_profile_dek_iv: String,
/// The recipients still sharing this profile after the rotation.
pub retained_recipient_user_ids: Vec<String>,
}
pub async fn rekey_profile(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Path(profile_id): Path<String>,
Json(req): Json<RekeyRequest>,
) -> Result<(StatusCode, Json<RekeyResponse>), (StatusCode, Json<serde_json::Value>)> {
// 1. Owner-only. (Admin-tier rekey is reserved for a later phase; the
// handler is owner-only for now.)
let profiles = profile_repo(&state);
let owner = match profiles
.find_by_profile_id_owned(&profile_id, &claims.sub)
.await
{
Ok(Some(p)) => p.owner_account_id,
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "profile not found" })),
));
}
Err(e) => {
tracing::error!("rekey profile lookup failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
// 2. Validate each submitted envelope: the owner can only rotate envelopes
// for recipients who ALREADY have an active share. (Adding a new share
// goes through POST /profiles/:id/shares, not rekey.) This prevents
// smuggling in a brand-new recipient via the rekey call.
let shares = share_repo(&state);
let active_shares = match shares.find_active_for_profile(&profile_id).await {
Ok(v) => v,
Err(e) => {
tracing::error!("rekey active-share lookup failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
let active_recipient_ids: std::collections::HashSet<&str> = active_shares
.iter()
.map(|s| s.recipient_user_id.as_str())
.collect();
for env in &req.recipient_envelopes {
if !active_recipient_ids.contains(env.recipient_user_id.as_str()) {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "recipient_envelope references a recipient with no active share; use POST /profiles/:id/shares to add a share",
"recipient_user_id": env.recipient_user_id,
})),
));
}
}
// 3. Rotate the owner share (store the new account-wrapped profile DEK).
let updated = match profiles
.update_wrapped_dek(
&profile_id,
&owner,
&req.new_wrapped_profile_dek,
&req.new_wrapped_profile_dek_iv,
)
.await
{
Ok(Some(p)) => p,
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "profile not found" })),
));
}
Err(e) => {
tracing::error!("rekey owner-share rotation failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
// 4. Upsert each submitted recipient envelope (fresh ECDH-wrapped DEK +
// ephemeral pubkey, replacing the old envelope). Preserve the original
// share's permissions / expires_at by reading them from the active share.
let now = DateTime::now();
for env in &req.recipient_envelopes {
let original = active_shares
.iter()
.find(|s| s.recipient_user_id == env.recipient_user_id);
let permissions = original
.map(|s| s.permissions.clone())
.unwrap_or_else(|| vec!["read".to_string()]);
let expires_at = original.and_then(|s| s.expires_at);
let new_share = ProfileShare {
id: None,
profile_id: profile_id.clone(),
owner_user_id: owner.clone(),
recipient_user_id: env.recipient_user_id.clone(),
ephemeral_public_key: env.ephemeral_public_key.clone(),
wrapped_profile_dek: env.wrapped_profile_dek.clone(),
wrapped_profile_dek_iv: env.wrapped_profile_dek_iv.clone(),
permissions,
expires_at,
created_at: now,
active: true,
};
if let Err(e) = shares.upsert(&new_share).await {
tracing::error!("rekey recipient upsert failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
}
// 5. Hard-revoke any current recipient NOT in the submitted list. Their
// cached old DEK no longer matches the rotated profile DEK, AND the
// server stops serving them.
let keep: Vec<String> = req
.recipient_envelopes
.iter()
.map(|e| e.recipient_user_id.clone())
.collect();
if let Err(e) = shares
.delete_for_profile_excluding(&profile_id, &keep)
.await
{
tracing::error!("rekey hard-revoke delete failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
Ok((
StatusCode::OK,
Json(RekeyResponse {
profile_id,
wrapped_profile_dek: updated.wrapped_profile_dek,
wrapped_profile_dek_iv: updated.wrapped_profile_dek_iv,
retained_recipient_user_ids: keep,
}),
))
}

View file

@ -152,6 +152,31 @@ impl ProfileRepository {
.await
}
/// Rotate the profile's wrapped DEK (owner share) — Phase C hard revoke.
/// Stores the new account-wrapped profile DEK verbatim, keyed by
/// (profile_id, owner). Returns the updated profile or None if it doesn't
/// exist / isn't owned by `owner`. The server stores the opaque blob
/// verbatim and cannot decrypt it.
pub async fn update_wrapped_dek(
&self,
profile_id: &str,
owner: &str,
new_wrapped_profile_dek: &str,
new_wrapped_profile_dek_iv: &str,
) -> mongodb::error::Result<Option<Profile>> {
self.collection
.find_one_and_update(
doc! { "profileId": profile_id, "ownerAccountId": owner },
doc! { "$set": {
"wrappedProfileDek": new_wrapped_profile_dek,
"wrappedProfileDekIv": new_wrapped_profile_dek_iv,
"updatedAt": DateTime::now()
}},
None,
)
.await
}
/// Delete a profile keyed by (profile_id, owner). Returns true if a doc
/// was deleted, false if it didn't exist or wasn't owned by `owner`.
pub async fn delete_profile(

View file

@ -142,6 +142,49 @@ impl ProfileShareRepository {
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(