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.
This commit is contained in:
parent
76fd5a8c26
commit
bf5aeedbc2
10 changed files with 861 additions and 1 deletions
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue