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.
382 lines
12 KiB
Rust
382 lines
12 KiB
Rust
//! 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;
|
|
}
|