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
382
backend/tests/rekey_tests.rs
Normal file
382
backend/tests/rekey_tests.rs
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
//! Hard-revoke / re-key integration tests (Phase C).
|
||||
//!
|
||||
//! Verifies POST /api/profiles/:id/rekey: rotating the profile's wrapped DEK,
|
||||
//! upserting submitted recipient envelopes (fresh ECDH wraps), and hard-
|
||||
//! deleting any current recipient NOT in the submitted list. Wire-level only
|
||||
//! (opaque blobs — the server never inspects them, so the tests don't need
|
||||
//! real crypto).
|
||||
//!
|
||||
//! Requires a live MongoDB; skips gracefully otherwise.
|
||||
|
||||
mod common;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
macro_rules! require_app {
|
||||
($app:expr) => {
|
||||
match $app {
|
||||
Some(x) => x,
|
||||
None => {
|
||||
eprintln!("[integration] skipped (MongoDB unavailable)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn unique_email() -> String {
|
||||
format!("test_{}@example.com", uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// Register a fully-set-up user (identity key + default self profile).
|
||||
async fn register_full(app: &axum::Router, email: &str) -> Value {
|
||||
let (status, body) = common::send_json(
|
||||
app,
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
Some(json!({
|
||||
"email": email,
|
||||
"username": email,
|
||||
"password": "supersecret",
|
||||
"identity_public_key": format!("pub-{email}"),
|
||||
"default_profile_name_data": "n",
|
||||
"default_profile_name_iv": "i",
|
||||
"default_wrapped_profile_dek": "owner-dek-v1",
|
||||
"default_wrapped_profile_dek_iv": "owner-dek-iv-v1",
|
||||
})),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201, "register_full failed, body: {body}");
|
||||
body
|
||||
}
|
||||
|
||||
fn token(body: &Value) -> String {
|
||||
body["token"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
fn self_profile_id(body: &Value) -> String {
|
||||
format!("profile_{}", body["user_id"].as_str().unwrap())
|
||||
}
|
||||
|
||||
/// Owner shares profile to recipient_email; returns the recipient's user_id
|
||||
/// (looked up from the share listing afterward).
|
||||
async fn share_to(
|
||||
app: &axum::Router,
|
||||
owner_token: &str,
|
||||
profile_id: &str,
|
||||
recipient_email: &str,
|
||||
) -> String {
|
||||
let (status, _) = common::send_json(
|
||||
app,
|
||||
"POST",
|
||||
&format!("/api/profiles/{profile_id}/shares"),
|
||||
Some(json!({
|
||||
"recipient_email": recipient_email,
|
||||
"ephemeral_public_key": format!("eph-{recipient_email}"),
|
||||
"wrapped_profile_dek": format!("wrap-{recipient_email}"),
|
||||
"wrapped_profile_dek_iv": "iv",
|
||||
"permissions": ["read"],
|
||||
})),
|
||||
Some(owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201, "share_to failed");
|
||||
// Look up the recipient's user_id from the listing.
|
||||
let (_, listing) = common::send_json(
|
||||
app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{profile_id}/shares"),
|
||||
None,
|
||||
Some(owner_token),
|
||||
)
|
||||
.await;
|
||||
listing
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|s| s["recipient_email"] == recipient_email)
|
||||
.unwrap()["recipient_user_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rekey_rotates_owner_share_and_keeps_listed_recipients() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let owner_body = register_full(&app, &unique_email()).await;
|
||||
let owner_token = token(&owner_body);
|
||||
let profile_id = self_profile_id(&owner_body);
|
||||
|
||||
let recipient_b = unique_email();
|
||||
let recipient_c = unique_email();
|
||||
register_full(&app, &recipient_b).await;
|
||||
register_full(&app, &recipient_c).await;
|
||||
let b_uid = share_to(&app, &owner_token, &profile_id, &recipient_b).await;
|
||||
let _c_uid = share_to(&app, &owner_token, &profile_id, &recipient_c).await;
|
||||
|
||||
// Owner rekeys: keeps B (fresh envelope), omits C (hard-revoke).
|
||||
let (status, body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/profiles/{profile_id}/rekey"),
|
||||
Some(json!({
|
||||
"new_wrapped_profile_dek": "owner-dek-v2",
|
||||
"new_wrapped_profile_dek_iv": "owner-dek-iv-v2",
|
||||
"recipient_envelopes": [{
|
||||
"recipient_user_id": b_uid,
|
||||
"ephemeral_public_key": "eph-b-v2",
|
||||
"wrapped_profile_dek": "wrap-b-v2",
|
||||
"wrapped_profile_dek_iv": "iv-v2",
|
||||
}],
|
||||
})),
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "rekey should return 200, body: {body}");
|
||||
// Owner share rotated.
|
||||
assert_eq!(body["wrapped_profile_dek"], "owner-dek-v2");
|
||||
assert_eq!(body["wrapped_profile_dek_iv"], "owner-dek-iv-v2");
|
||||
// Retained = [B].
|
||||
let retained: Vec<&str> = body["retained_recipient_user_ids"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(retained, vec![b_uid.as_str()]);
|
||||
|
||||
// The profile's stored owner-share reflects v2.
|
||||
let (_, profile_body) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{profile_id}"),
|
||||
None,
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(profile_body["wrapped_profile_dek"], "owner-dek-v2");
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rekey_hard_revokes_omitted_recipient() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let owner_body = register_full(&app, &unique_email()).await;
|
||||
let owner_token = token(&owner_body);
|
||||
let profile_id = self_profile_id(&owner_body);
|
||||
|
||||
let recipient_b = unique_email();
|
||||
let recipient_c = unique_email();
|
||||
let b_body = register_full(&app, &recipient_b).await;
|
||||
let c_body = register_full(&app, &recipient_c).await;
|
||||
let b_token = token(&b_body);
|
||||
let c_token = token(&c_body);
|
||||
let b_uid = share_to(&app, &owner_token, &profile_id, &recipient_b).await;
|
||||
let _c_uid = share_to(&app, &owner_token, &profile_id, &recipient_c).await;
|
||||
|
||||
// Before rekey: both recipients see the profile in shared-with-me.
|
||||
let (_, shared_before) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
"/api/profiles/shared-with-me",
|
||||
None,
|
||||
Some(&c_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
shared_before.as_array().unwrap().len(),
|
||||
1,
|
||||
"C should see the shared profile before rekey"
|
||||
);
|
||||
|
||||
// Rekey keeping only B.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/profiles/{profile_id}/rekey"),
|
||||
Some(json!({
|
||||
"new_wrapped_profile_dek": "owner-dek-v2",
|
||||
"new_wrapped_profile_dek_iv": "owner-dek-iv-v2",
|
||||
"recipient_envelopes": [{
|
||||
"recipient_user_id": b_uid,
|
||||
"ephemeral_public_key": "eph-b-v2",
|
||||
"wrapped_profile_dek": "wrap-b-v2",
|
||||
"wrapped_profile_dek_iv": "iv-v2",
|
||||
}],
|
||||
})),
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200);
|
||||
|
||||
// B (kept) still sees it, with the rotated envelope.
|
||||
let (_, shared_b) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
"/api/profiles/shared-with-me",
|
||||
None,
|
||||
Some(&b_token),
|
||||
)
|
||||
.await;
|
||||
let arr_b = shared_b.as_array().unwrap();
|
||||
assert_eq!(arr_b.len(), 1, "B sees exactly the one shared profile");
|
||||
assert_eq!(
|
||||
arr_b[0]["wrapped_profile_dek"], "wrap-b-v2",
|
||||
"B's envelope rotated"
|
||||
);
|
||||
|
||||
// C (omitted) no longer sees it.
|
||||
let (_, shared_c) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
"/api/profiles/shared-with-me",
|
||||
None,
|
||||
Some(&c_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
shared_c.as_array().unwrap().len(),
|
||||
0,
|
||||
"C must be hard-revoked from shared-with-me"
|
||||
);
|
||||
// And the share-gate 404s C on direct read.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{profile_id}"),
|
||||
None,
|
||||
Some(&c_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status, 404,
|
||||
"C must be denied by the gate after hard revoke"
|
||||
);
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rekey_rejects_envelope_for_recipient_with_no_share() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let owner_body = register_full(&app, &unique_email()).await;
|
||||
let owner_token = token(&owner_body);
|
||||
let profile_id = self_profile_id(&owner_body);
|
||||
|
||||
// A recipient who was never shared with.
|
||||
let stranger_body = register_full(&app, &unique_email()).await;
|
||||
let stranger_uid = stranger_body["user_id"].as_str().unwrap().to_string();
|
||||
|
||||
let (status, body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/profiles/{profile_id}/rekey"),
|
||||
Some(json!({
|
||||
"new_wrapped_profile_dek": "owner-dek-v2",
|
||||
"new_wrapped_profile_dek_iv": "owner-dek-iv-v2",
|
||||
"recipient_envelopes": [{
|
||||
"recipient_user_id": stranger_uid,
|
||||
"ephemeral_public_key": "x",
|
||||
"wrapped_profile_dek": "y",
|
||||
"wrapped_profile_dek_iv": "z",
|
||||
}],
|
||||
})),
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status, 400,
|
||||
"envelope for a recipient with no share must be rejected: {body}"
|
||||
);
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rekey_rejects_non_owner() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let owner_body = register_full(&app, &unique_email()).await;
|
||||
let profile_id = self_profile_id(&owner_body);
|
||||
|
||||
let other_token = token(®ister_full(&app, &unique_email()).await);
|
||||
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/profiles/{profile_id}/rekey"),
|
||||
Some(json!({
|
||||
"new_wrapped_profile_dek": "x",
|
||||
"new_wrapped_profile_dek_iv": "y",
|
||||
"recipient_envelopes": [],
|
||||
})),
|
||||
Some(&other_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404, "non-owner rekey must be rejected");
|
||||
|
||||
// Profile is unchanged.
|
||||
let (_, profile_body) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{profile_id}"),
|
||||
None,
|
||||
Some(token(&owner_body).as_str()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(profile_body["wrapped_profile_dek"], "owner-dek-v1");
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rekey_with_empty_envelopes_revokes_all_recipients() {
|
||||
// Keeping nobody = hard-revoke everyone (e.g. suspected compromise).
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let owner_body = register_full(&app, &unique_email()).await;
|
||||
let owner_token = token(&owner_body);
|
||||
let profile_id = self_profile_id(&owner_body);
|
||||
|
||||
let recipient_b = unique_email();
|
||||
register_full(&app, &recipient_b).await;
|
||||
share_to(&app, &owner_token, &profile_id, &recipient_b).await;
|
||||
|
||||
let (status, body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/profiles/{profile_id}/rekey"),
|
||||
Some(json!({
|
||||
"new_wrapped_profile_dek": "owner-dek-v2",
|
||||
"new_wrapped_profile_dek_iv": "owner-dek-iv-v2",
|
||||
"recipient_envelopes": [],
|
||||
})),
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status, 200,
|
||||
"rekey with no envelopes should succeed: {body}"
|
||||
);
|
||||
assert_eq!(
|
||||
body["retained_recipient_user_ids"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
0
|
||||
);
|
||||
|
||||
// The shares listing is now empty.
|
||||
let (_, listing) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{profile_id}/shares"),
|
||||
None,
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(listing.as_array().unwrap().len(), 0);
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue