diff --git a/backend/src/app.rs b/backend/src/app.rs index e300abe..00e7667 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -66,8 +66,6 @@ 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)) diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 68ac996..665fe4e 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -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, rekey_profile, + list_shares, }; pub use sessions::{get_sessions, revoke_all_sessions, revoke_session}; pub use users::{ diff --git a/backend/src/handlers/profile_share.rs b/backend/src/handlers/profile_share.rs index 3895fb1..d29e833 100644 --- a/backend/src/handlers/profile_share.rs +++ b/backend/src/handlers/profile_share.rs @@ -511,198 +511,3 @@ 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, -} - -#[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, -} - -pub async fn rekey_profile( - State(state): State, - Extension(claims): Extension, - Path(profile_id): Path, - Json(req): Json, -) -> Result<(StatusCode, Json), (StatusCode, Json)> { - // 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). - // update_wrapped_dek uses find_one_and_update, which returns the PRE-image - // by default — so we don't read the new values back from it; we echo the - // request values (exactly what was stored) in the response below. - match profiles - .update_wrapped_dek( - &profile_id, - &owner, - &req.new_wrapped_profile_dek, - &req.new_wrapped_profile_dek_iv, - ) - .await - { - Ok(Some(_)) => {} - 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 = 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, - // Echo the request values (exactly what was stored). Avoids the - // find_one_and_update pre-image gotcha. - wrapped_profile_dek: req.new_wrapped_profile_dek, - wrapped_profile_dek_iv: req.new_wrapped_profile_dek_iv, - retained_recipient_user_ids: keep, - }), - )) -} diff --git a/backend/src/models/profile.rs b/backend/src/models/profile.rs index 4852243..288e4d7 100644 --- a/backend/src/models/profile.rs +++ b/backend/src/models/profile.rs @@ -152,31 +152,6 @@ 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> { - 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( diff --git a/backend/src/models/profile_share.rs b/backend/src/models/profile_share.rs index 1fc50fd..f3929c7 100644 --- a/backend/src/models/profile_share.rs +++ b/backend/src/models/profile_share.rs @@ -142,49 +142,6 @@ 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> { - 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 { - 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( diff --git a/backend/tests/rekey_tests.rs b/backend/tests/rekey_tests.rs deleted file mode 100644 index 5bb4893..0000000 --- a/backend/tests/rekey_tests.rs +++ /dev/null @@ -1,382 +0,0 @@ -//! 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; -} diff --git a/web/normogen-web/src/components/profile/ProfileSharing.tsx b/web/normogen-web/src/components/profile/ProfileSharing.tsx index 54497dd..12214fe 100644 --- a/web/normogen-web/src/components/profile/ProfileSharing.tsx +++ b/web/normogen-web/src/components/profile/ProfileSharing.tsx @@ -15,7 +15,6 @@ import { } from '@mui/material'; import PersonAddIcon from '@mui/icons-material/PersonAdd'; import PersonRemoveIcon from '@mui/icons-material/PersonRemove'; -import KeyIcon from '@mui/icons-material/Key'; import { useProfileStore } from '../../store/useStore'; /** Owner-side sharing UI for a single owned profile: lists the recipients the @@ -28,7 +27,6 @@ export const ProfileSharing: FC<{ profileId: string }> = ({ profileId }) => { loadProfileShares, shareProfile, revokeShare, - rekeyProfile, error, clearError, } = useProfileStore(); @@ -68,36 +66,6 @@ export const ProfileSharing: FC<{ profileId: string }> = ({ profileId }) => { } }; - const handleHardRevoke = async () => { - // Hard revoke = rotate the profile DEK. All currently-listed recipients - // are KEPT (re-wrapped to the new DEK); the owner removes specific - // recipients via the per-row revoke button above first if they want them - // gone at the key level. Expensive (re-encrypts all the profile's data), - // but resume-safe — partial runs can be retried. - const ok = confirm( - 'Rotate this profile\'s encryption key?\n\n' + - 'This re-encrypts ALL the profile\'s data (medications, appointments, ' + - 'health stats) under a new key — it may take a moment. All currently-' + - 'listed recipients keep access (re-wrapped to the new key). Anyone who ' + - 'previously had access and was removed, or any cached copy of the old ' + - 'key, will stop working.\n\nUse this if you suspect a key was compromised.', - ); - if (!ok) return; - setBusy(true); - setLocalError(''); - try { - const keepIds = shares.map((s) => s.recipient_user_id); - await rekeyProfile(profileId, keepIds); - } catch (e: any) { - setLocalError( - (e?.message || 'Re-key failed') + - ' — you can retry; rows already re-encrypted are skipped.', - ); - } finally { - setBusy(false); - } - }; - return ( @@ -165,23 +133,6 @@ export const ProfileSharing: FC<{ profileId: string }> = ({ profileId }) => { The recipient must have a Normogen account. They'll see this profile in their switcher. - - - - - Generates a new key and re-encrypts this profile's data. Use if a key - may have been compromised. Resume-safe — you can retry on failure. - - ); }; diff --git a/web/normogen-web/src/services/api.ts b/web/normogen-web/src/services/api.ts index 12c5bc5..753fab8 100644 --- a/web/normogen-web/src/services/api.ts +++ b/web/normogen-web/src/services/api.ts @@ -24,8 +24,6 @@ import { CreateProfileShareRequest, ProfileShareListing, PublicKeyResponse, - RekeyRequest, - RekeyResponse, HealthStatWireResponse, UpdateHealthStatRequest, EncryptedFieldWire, @@ -310,18 +308,6 @@ class ApiService { await this.client.delete(`/profiles/${profileId}/shares/${recipientUserId}`); } - /** Owner: hard revoke / rotate the profile DEK (Phase C). The client has - * already re-encrypted the data under a new DEK (client-side) and wrapped - * it to the owner account DEK + each kept recipient. Recipients omitted - * from `recipient_envelopes` are hard-revoked. */ - async rekeyProfile(profileId: string, req: RekeyRequest): Promise { - const response = await this.client.post( - `/profiles/${profileId}/rekey`, - req, - ); - return response.data; - } - // ---- Medications (zero-knowledge: opaque encrypted blobs) ---- async getMedications(profileId?: string): Promise { diff --git a/web/normogen-web/src/store/useStore.ts b/web/normogen-web/src/store/useStore.ts index 22daa32..a7692b1 100644 --- a/web/normogen-web/src/store/useStore.ts +++ b/web/normogen-web/src/store/useStore.ts @@ -41,9 +41,6 @@ import { AdherenceStats, Profile, ProfileShareListing, - MedicationWireResponse, - AppointmentWireResponse, - HealthStatWireResponse, Appointment, CreateAppointmentRequest, UpdateAppointmentRequest, @@ -156,14 +153,6 @@ interface ProfileState { /** Shares the current user has created for a profile (for the owner UI). */ profileShares: Record; loadProfileShares: (profileId: string) => Promise; - /** Owner: hard revoke / rotate the profile DEK. Re-encrypts all the - * profile's data under a fresh DEK (client-side), then commits the - * rotation server-side. Recipients not in `keepRecipientUserIds` are - * hard-revoked (lose access at the key level). Expensive: O(data size). */ - rekeyProfile: ( - profileId: string, - keepRecipientUserIds: string[], - ) => Promise; clearError: () => void; } @@ -1132,127 +1121,6 @@ export const useProfileStore = create()( } }, - rekeyProfile: async (profileId, keepRecipientUserIds) => { - // Phase C hard revoke. Re-encrypts all the profile's data client-side - // under a fresh DEK, then commits the rotation. Resume-safe per-row: - // if a row was already re-encrypted on a prior partial run, decrypt- - // with-old fails and we try decrypt-with-new; if that succeeds, the - // row is already on the new DEK and we skip it. - const accountDek = getEncKey(); - const oldDek = getProfileDek(profileId); - const myIdentityPrivate = getIdentityPrivate(); - if (!accountDek || !oldDek) { - set({ error: 'Unlock the profile first (no account/profile key)' }); - throw new Error('No keys'); - } - set({ isLoading: true, error: null }); - try { - const newDek = await generateProfileDek(); - - // 3a. Re-encrypt every data row under the new DEK. - // medications + appointments: POST /:id with the new blob. - // health-stats: PUT /:id with the new blob. - const reencryptRow = async ( - getBlob: () => Promise<{ data: string; iv: string } | null>, - reencrypt: (newBlob: { data: string; iv: string }) => Promise, - ) => { - const blob = await getBlob(); - if (!blob) return; - let plaintext: string; - try { - plaintext = await decryptRaw(blob, oldDek); - } catch { - // Maybe already re-encrypted on a prior partial run — verify with - // the new DEK, and skip if so. - try { - await decryptRaw(blob, newDek); - return; // already on newDek - } catch { - throw new Error('row could not be decrypted with old or new DEK'); - } - } - const newBlob = await encryptRaw(plaintext, newDek); - await reencrypt(newBlob); - }; - - const meds: MedicationWireResponse[] = await apiService.getMedications(profileId); - for (const m of meds) { - await reencryptRow( - async () => (m.encrypted_data?.data ? m.encrypted_data : null), - async (newBlob) => { - await apiService.updateMedication(m.medication_id, { - encrypted_data: newBlob, - }); - }, - ); - } - const appts: AppointmentWireResponse[] = await apiService.getAppointments( - undefined, - profileId, - ); - for (const a of appts) { - await reencryptRow( - async () => (a.encrypted_data?.data ? a.encrypted_data : null), - async (newBlob) => { - await apiService.updateAppointment(a.appointment_id, { encrypted_data: newBlob }); - }, - ); - } - const stats: HealthStatWireResponse[] = await apiService.getHealthStats(profileId); - for (const s of stats) { - await reencryptRow( - async () => (s.encrypted_data?.data ? s.encrypted_data : null), - async (newBlob) => { - await apiService.updateHealthStat(s.id, { encrypted_data: newBlob }); - }, - ); - } - - // 3b. Build a fresh ECDH envelope per kept recipient. - const recipientEnvelopes = []; - if (myIdentityPrivate) { - const shares = get().profileShares[profileId] ?? []; - for (const s of shares) { - if (!keepRecipientUserIds.includes(s.recipient_user_id)) continue; - const { identity_public_key: recipientPub } = await apiService.getUserPublicKey( - s.recipient_email, - ); - const env = await wrapProfileDekToRecipient( - newDek, - recipientPub, - myIdentityPrivate, - ); - recipientEnvelopes.push({ - recipient_user_id: s.recipient_user_id, - ephemeral_public_key: env.ephemeralPublicKey, - wrapped_profile_dek: env.wrappedProfileDek.data, - wrapped_profile_dek_iv: env.wrappedProfileDek.iv, - }); - } - } - - // 3c. Commit: rotate the owner share (new DEK wrapped under the account - // DEK) + recipient envelopes, hard-delete omitted recipients. - const ownerWrap = await wrapProfileDek(newDek, accountDek); - await apiService.rekeyProfile(profileId, { - new_wrapped_profile_dek: ownerWrap.data, - new_wrapped_profile_dek_iv: ownerWrap.iv, - recipient_envelopes: recipientEnvelopes, - }); - - // 3d. Swap the in-memory DEK and refresh the share listing. - setProfileDek(profileId, newDek); - await get().loadProfileShares(profileId); - set({ isLoading: false }); - } catch (error: any) { - set({ - error: error.message || 'Re-key failed (you can retry — already-re-encrypted rows are skipped)', - isLoading: false, - }); - throw error; - } - }, - clearError: () => set({ error: null }), })), ); diff --git a/web/normogen-web/src/types/api.ts b/web/normogen-web/src/types/api.ts index 9904310..6a719b2 100644 --- a/web/normogen-web/src/types/api.ts +++ b/web/normogen-web/src/types/api.ts @@ -408,29 +408,6 @@ export interface ProfileShareListing { active: boolean; } -// Phase C hard revoke: one fresh ECDH envelope per recipient the owner keeps. -export interface RecipientEnvelope { - recipient_user_id: string; - ephemeral_public_key: string; - wrapped_profile_dek: string; - wrapped_profile_dek_iv: string; -} - -// Request body for POST /profiles/:id/rekey (hard revoke / rotate the DEK). -export interface RekeyRequest { - new_wrapped_profile_dek: string; - new_wrapped_profile_dek_iv: string; - recipient_envelopes: RecipientEnvelope[]; -} - -// Response from POST /profiles/:id/rekey. -export interface RekeyResponse { - profile_id: string; - wrapped_profile_dek: string; - wrapped_profile_dek_iv: string; - retained_recipient_user_ids: string[]; -} - // Response from GET /users/public-key?email=... export interface PublicKeyResponse { user_id: string;