Compare commits
No commits in common. "dabbea2483c8822dad08626062c6568f0d7563a6" and "668ea590305b3d3896f449efb901253fdbbd42a3" have entirely different histories.
dabbea2483
...
668ea59030
10 changed files with 1 additions and 866 deletions
|
|
@ -66,8 +66,6 @@ pub fn build_app(state: AppState) -> Router {
|
||||||
get(handlers::list_shares).post(handlers::create_share),
|
get(handlers::list_shares).post(handlers::create_share),
|
||||||
)
|
)
|
||||||
.route("/api/profiles/:id/shares/:recipient", delete(handlers::delete_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)
|
// Session management (Phase 2.6)
|
||||||
.route("/api/sessions", get(handlers::get_sessions))
|
.route("/api/sessions", get(handlers::get_sessions))
|
||||||
.route("/api/sessions/:id", delete(handlers::revoke_session))
|
.route("/api/sessions/:id", delete(handlers::revoke_session))
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ pub use medications::{
|
||||||
pub use profile::{create_profile, delete_profile, get_profile, list_profiles, update_profile};
|
pub use profile::{create_profile, delete_profile, get_profile, list_profiles, update_profile};
|
||||||
pub use profile_share::{
|
pub use profile_share::{
|
||||||
create_share, delete_share, get_profile_shared_aware, get_public_key, list_shared_with_me,
|
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 sessions::{get_sessions, revoke_all_sessions, revoke_session};
|
||||||
pub use users::{
|
pub use users::{
|
||||||
|
|
|
||||||
|
|
@ -511,198 +511,3 @@ pub async fn get_public_key(
|
||||||
identity_public_key: pk,
|
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<RecipientEnvelope>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn rekey_profile(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Extension(claims): Extension<Claims>,
|
|
||||||
Path(profile_id): Path<String>,
|
|
||||||
Json(req): Json<RekeyRequest>,
|
|
||||||
) -> Result<(StatusCode, Json<RekeyResponse>), (StatusCode, Json<serde_json::Value>)> {
|
|
||||||
// 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<String> = 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,
|
|
||||||
}),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -152,31 +152,6 @@ impl ProfileRepository {
|
||||||
.await
|
.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<Option<Profile>> {
|
|
||||||
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
|
/// 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`.
|
/// was deleted, false if it didn't exist or wasn't owned by `owner`.
|
||||||
pub async fn delete_profile(
|
pub async fn delete_profile(
|
||||||
|
|
|
||||||
|
|
@ -142,49 +142,6 @@ impl ProfileShareRepository {
|
||||||
self.collection.find_one(filter, None).await
|
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
|
/// Hard-delete (soft-revoke) the (profile, recipient) share. Returns true
|
||||||
/// if a doc was deleted.
|
/// if a doc was deleted.
|
||||||
pub async fn delete(
|
pub async fn delete(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -15,7 +15,6 @@ import {
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import PersonAddIcon from '@mui/icons-material/PersonAdd';
|
import PersonAddIcon from '@mui/icons-material/PersonAdd';
|
||||||
import PersonRemoveIcon from '@mui/icons-material/PersonRemove';
|
import PersonRemoveIcon from '@mui/icons-material/PersonRemove';
|
||||||
import KeyIcon from '@mui/icons-material/Key';
|
|
||||||
import { useProfileStore } from '../../store/useStore';
|
import { useProfileStore } from '../../store/useStore';
|
||||||
|
|
||||||
/** Owner-side sharing UI for a single owned profile: lists the recipients the
|
/** 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,
|
loadProfileShares,
|
||||||
shareProfile,
|
shareProfile,
|
||||||
revokeShare,
|
revokeShare,
|
||||||
rekeyProfile,
|
|
||||||
error,
|
error,
|
||||||
clearError,
|
clearError,
|
||||||
} = useProfileStore();
|
} = 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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>
|
<Typography variant="subtitle1" sx={{ mb: 1 }}>
|
||||||
|
|
@ -165,23 +133,6 @@ export const ProfileSharing: FC<{ profileId: string }> = ({ profileId }) => {
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||||
The recipient must have a Normogen account. They'll see this profile in their switcher.
|
The recipient must have a Normogen account. They'll see this profile in their switcher.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ mt: 3, pt: 2, borderTop: 1, borderColor: 'divider' }}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
color="warning"
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<KeyIcon />}
|
|
||||||
disabled={busy}
|
|
||||||
onClick={handleHardRevoke}
|
|
||||||
>
|
|
||||||
{busy ? <CircularProgress size={20} /> : 'Rotate encryption key (hard revoke)'}
|
|
||||||
</Button>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
|
||||||
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.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,6 @@ import {
|
||||||
CreateProfileShareRequest,
|
CreateProfileShareRequest,
|
||||||
ProfileShareListing,
|
ProfileShareListing,
|
||||||
PublicKeyResponse,
|
PublicKeyResponse,
|
||||||
RekeyRequest,
|
|
||||||
RekeyResponse,
|
|
||||||
HealthStatWireResponse,
|
HealthStatWireResponse,
|
||||||
UpdateHealthStatRequest,
|
UpdateHealthStatRequest,
|
||||||
EncryptedFieldWire,
|
EncryptedFieldWire,
|
||||||
|
|
@ -310,18 +308,6 @@ class ApiService {
|
||||||
await this.client.delete(`/profiles/${profileId}/shares/${recipientUserId}`);
|
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<RekeyResponse> {
|
|
||||||
const response = await this.client.post<RekeyResponse>(
|
|
||||||
`/profiles/${profileId}/rekey`,
|
|
||||||
req,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Medications (zero-knowledge: opaque encrypted blobs) ----
|
// ---- Medications (zero-knowledge: opaque encrypted blobs) ----
|
||||||
|
|
||||||
async getMedications(profileId?: string): Promise<MedicationWireResponse[]> {
|
async getMedications(profileId?: string): Promise<MedicationWireResponse[]> {
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,6 @@ import {
|
||||||
AdherenceStats,
|
AdherenceStats,
|
||||||
Profile,
|
Profile,
|
||||||
ProfileShareListing,
|
ProfileShareListing,
|
||||||
MedicationWireResponse,
|
|
||||||
AppointmentWireResponse,
|
|
||||||
HealthStatWireResponse,
|
|
||||||
Appointment,
|
Appointment,
|
||||||
CreateAppointmentRequest,
|
CreateAppointmentRequest,
|
||||||
UpdateAppointmentRequest,
|
UpdateAppointmentRequest,
|
||||||
|
|
@ -156,14 +153,6 @@ interface ProfileState {
|
||||||
/** Shares the current user has created for a profile (for the owner UI). */
|
/** Shares the current user has created for a profile (for the owner UI). */
|
||||||
profileShares: Record<string, ProfileShareListing[]>;
|
profileShares: Record<string, ProfileShareListing[]>;
|
||||||
loadProfileShares: (profileId: string) => Promise<void>;
|
loadProfileShares: (profileId: string) => Promise<void>;
|
||||||
/** 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<void>;
|
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1132,127 +1121,6 @@ export const useProfileStore = create<ProfileState>()(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
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<void>,
|
|
||||||
) => {
|
|
||||||
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 }),
|
clearError: () => set({ error: null }),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -408,29 +408,6 @@ export interface ProfileShareListing {
|
||||||
active: boolean;
|
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=...
|
// Response from GET /users/public-key?email=...
|
||||||
export interface PublicKeyResponse {
|
export interface PublicKeyResponse {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue