normogen/backend/tests/rekey_tests.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

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(&register_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;
}