diff --git a/.gitignore b/.gitignore index bdfa7c1..6098d17 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .env.local .env.*.local .forgejo-token +.zcode/ node_modules/ dist/ target/ diff --git a/backend/src/app.rs b/backend/src/app.rs index fc19592..e300abe 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -35,28 +35,39 @@ pub fn build_app(state: AppState) -> Router { "/api/auth/recover-password", post(handlers::recover_password), ) - .route("/api/auth/recovery-info", get(handlers::recovery_info)); + .route("/api/auth/recovery-info", get(handlers::recovery_info)) + // Recipient identity public key lookup (Phase B). Public keys are not + // secret, so this is unauthenticated — mirrors recovery-info. + .route("/api/users/public-key", get(handlers::get_public_key)); // Build protected routes (auth required) let protected_routes = Router::new() // User profile management - .route("/api/users/me", get(handlers::get_profile)) - .route("/api/users/me", put(handlers::update_profile)) + .route("/api/users/me", get(handlers::get_account)) + .route("/api/users/me", put(handlers::update_account)) .route("/api/users/me", delete(handlers::delete_account)) .route("/api/users/me/change-password", post(handlers::change_password)) // User settings .route("/api/users/me/settings", get(handlers::get_settings)) .route("/api/users/me/settings", put(handlers::update_settings)) - // Share management - .route("/api/shares", post(handlers::create_share)) - .route("/api/shares", get(handlers::list_shares)) - .route("/api/shares/:id", put(handlers::update_share)) - .route("/api/shares/:id", delete(handlers::delete_share)) - // Permission checking - .route("/api/permissions/check", post(handlers::check_permission)) - // Profile management (Phase 3c) - .route("/api/profiles/me", get(handlers::get_my_profile)) - .route("/api/profiles/me", put(handlers::update_my_profile)) + // Profile management (Phase A2 multi-profile + Phase B sharing). + // GET /api/profiles/:id is share-aware (admits recipients); the other + // profile ops remain owner-only. + .route("/api/profiles", get(handlers::list_profiles)) + .route("/api/profiles", post(handlers::create_profile)) + .route("/api/profiles/shared-with-me", get(handlers::list_shared_with_me)) + .route("/api/profiles/:id", get(handlers::get_profile_shared_aware)) + .route("/api/profiles/:id", put(handlers::update_profile)) + .route("/api/profiles/:id", delete(handlers::delete_profile)) + // Profile sharing (Phase B): owner manages shares; recipients read via + // the gate inside the data handlers. + .route( + "/api/profiles/:id/shares", + 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/auth/claims.rs b/backend/src/auth/claims.rs deleted file mode 100644 index 2bdf5cc..0000000 --- a/backend/src/auth/claims.rs +++ /dev/null @@ -1,22 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AccessClaims { - pub sub: String, - pub email: String, - pub family_id: Option, - pub permissions: Vec, - pub token_type: String, - pub iat: i64, - pub exp: i64, - pub jti: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RefreshClaims { - pub sub: String, - pub token_type: String, - pub iat: i64, - pub exp: i64, - pub jti: String, -} diff --git a/backend/src/auth/password.rs b/backend/src/auth/password.rs index 04e3bbd..efe5383 100644 --- a/backend/src/auth/password.rs +++ b/backend/src/auth/password.rs @@ -1,6 +1,9 @@ use anyhow::Result; use pbkdf2::{ - password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + password_hash::{ + rand_core::OsRng, Error as PasswordHashError, PasswordHash, PasswordHasher, + PasswordVerifier, SaltString, + }, Pbkdf2, }; @@ -15,14 +18,24 @@ impl PasswordService { Ok(password_hash.to_string()) } + /// Verify a password against a stored PHC string. + /// + /// Returns `Ok(true)` on a match, `Ok(false)` on a mismatch (wrong + /// password — an expected outcome, not an error), and `Err(...)` only for + /// genuine failures (unparseable hash, malformed input, etc.). Returning + /// `Ok(false)` for a wrong password lets the login handler distinguish + /// "invalid credentials" (401) from a real backend fault (500). pub fn verify_password(password: &str, hash: &str) -> Result { let parsed_hash = PasswordHash::new(hash) .map_err(|e| anyhow::anyhow!("Failed to parse password hash: {}", e))?; - Pbkdf2 - .verify_password(password.as_bytes(), &parsed_hash) - .map(|_| true) - .map_err(|e| anyhow::anyhow!("Password verification failed: {}", e)) + match Pbkdf2.verify_password(password.as_bytes(), &parsed_hash) { + Ok(()) => Ok(true), + // `Error::Password` is the password-hash crate's signal for a + // mismatch — the one expected "failure" during normal auth. + Err(PasswordHashError::Password) => Ok(false), + Err(e) => Err(anyhow::anyhow!("Password verification failed: {}", e)), + } } } diff --git a/backend/src/db/init.rs b/backend/src/db/init.rs index 8193095..6cdd71f 100644 --- a/backend/src/db/init.rs +++ b/backend/src/db/init.rs @@ -98,15 +98,30 @@ impl DatabaseInitializer { println!("✓ Created appointments collection"); } - // Create shares collection and index + // Create profile_shares collection and indexes (Phase B — replaces the + // legacy shares collection). Lookups are by (profileId, recipientUserId) + // and by recipientUserId alone (for /profiles/shared-with-me). { - let collection: Collection = self.db.collection("shares"); + let collection: Collection = + self.db.collection("profile_shares"); - let index = IndexModel::builder().keys(doc! { "familyId": 1 }).build(); + let pair_index = IndexModel::builder() + .keys(doc! { "profileId": 1, "recipientUserId": 1 }) + .build(); + match collection.create_index(pair_index, None).await { + Ok(_) => println!("✓ Created index on profile_shares (profileId, recipientUserId)"), + Err(e) => println!("Warning: Failed to create profile_shares pair index: {}", e), + } - match collection.create_index(index, None).await { - Ok(_) => println!("✓ Created index on shares.familyId"), - Err(e) => println!("Warning: Failed to create index on shares.familyId: {}", e), + let recipient_index = IndexModel::builder() + .keys(doc! { "recipientUserId": 1 }) + .build(); + match collection.create_index(recipient_index, None).await { + Ok(_) => println!("✓ Created index on profile_shares.recipientUserId"), + Err(e) => println!( + "Warning: Failed to create profile_shares recipient index: {}", + e + ), } } diff --git a/backend/src/db/mongodb_impl.rs b/backend/src/db/mongodb_impl.rs index e1410ae..64952df 100644 --- a/backend/src/db/mongodb_impl.rs +++ b/backend/src/db/mongodb_impl.rs @@ -5,8 +5,6 @@ use std::time::Duration; use crate::models::{ medication::{Medication, MedicationDose, MedicationRepository, UpdateMedicationRequest}, - permission::Permission, - share::{Share, ShareRepository}, user::{User, UserRepository}, }; @@ -14,7 +12,6 @@ use crate::models::{ pub struct MongoDb { database: Database, pub users: Collection, - pub shares: Collection, pub medications: Collection, pub medication_doses: Collection, } @@ -62,7 +59,6 @@ impl MongoDb { return Ok(Self { users: database.collection("users"), - shares: database.collection("shares"), medications: database.collection("medications"), medication_doses: database.collection("medication_doses"), database, @@ -97,7 +93,6 @@ impl MongoDb { return Ok(Self { users: database.collection("users"), - shares: database.collection("shares"), medications: database.collection("medications"), medication_doses: database.collection("medication_doses"), database, @@ -113,7 +108,6 @@ impl MongoDb { Ok(Self { users: database.collection("users"), - shares: database.collection("shares"), medications: database.collection("medications"), medication_doses: database.collection("medication_doses"), database, @@ -177,100 +171,6 @@ impl MongoDb { Ok(()) } - // ===== Share Methods ===== - - pub async fn create_share(&self, share: &Share) -> Result> { - let repo = ShareRepository::new(self.shares.clone()); - Ok(repo.create(share).await?) - } - - pub async fn get_share(&self, id: &str) -> Result> { - let object_id = ObjectId::parse_str(id)?; - let repo = ShareRepository::new(self.shares.clone()); - Ok(repo.find_by_id(&object_id).await?) - } - - pub async fn list_shares_for_user(&self, user_id: &str) -> Result> { - let object_id = ObjectId::parse_str(user_id)?; - let repo = ShareRepository::new(self.shares.clone()); - Ok(repo.find_by_target(&object_id).await?) - } - - pub async fn update_share(&self, share: &Share) -> Result<()> { - let repo = ShareRepository::new(self.shares.clone()); - repo.update(share).await?; - Ok(()) - } - - pub async fn delete_share(&self, id: &str) -> Result<()> { - let object_id = ObjectId::parse_str(id)?; - let repo = ShareRepository::new(self.shares.clone()); - repo.delete(&object_id).await?; - Ok(()) - } - - // ===== Permission Methods ===== - - pub async fn check_user_permission( - &self, - user_id: &str, - resource_type: &str, - resource_id: &str, - permission: &str, - ) -> Result { - let user_oid = ObjectId::parse_str(user_id)?; - let resource_oid = ObjectId::parse_str(resource_id)?; - - let repo = ShareRepository::new(self.shares.clone()); - let shares = repo.find_by_target(&user_oid).await?; - - for share in shares { - if share.resource_type == resource_type - && share.resource_id.as_ref() == Some(&resource_oid) - && share.active - && !share.is_expired() - { - // Check if share has the required permission - let perm = match permission.to_lowercase().as_str() { - "read" => Permission::Read, - "write" => Permission::Write, - "delete" => Permission::Delete, - "share" => Permission::Share, - "admin" => Permission::Admin, - _ => return Ok(false), - }; - - if share.has_permission(&perm) { - return Ok(true); - } - } - } - - Ok(false) - } - - /// Check permission using a simplified interface - pub async fn check_permission( - &self, - user_id: &str, - resource_id: &str, - permission: &str, - ) -> Result { - // For now, check all resource types - let resource_types = ["profiles", "health_data", "lab_results", "medications"]; - - for resource_type in resource_types { - if self - .check_user_permission(user_id, resource_type, resource_id, permission) - .await? - { - return Ok(true); - } - } - - Ok(false) - } - // ===== Medication Methods (Fixed for Phase 2.8) ===== pub async fn create_medication(&self, medication: &Medication) -> Result> { diff --git a/backend/src/handlers/appointments.rs b/backend/src/handlers/appointments.rs index 9d9f054..e95fbde 100644 --- a/backend/src/handlers/appointments.rs +++ b/backend/src/handlers/appointments.rs @@ -20,6 +20,8 @@ use mongodb::bson::DateTime; pub struct AppointmentListQuery { /// Filter by status (upcoming/completed/cancelled). Optional. pub status: Option, + /// Filter by profile id. Optional. + pub profile_id: Option, } pub async fn create_appointment( @@ -63,64 +65,175 @@ pub async fn list_appointments( State(state): State, Extension(claims): Extension, Query(query): Query, -) -> Result>, StatusCode> { +) -> Result>, (StatusCode, Json)> { let database = state.db.get_database(); let repo = AppointmentRepository::new(database.collection("appointments")); + // Resolve the effective owner. If a profile_id is specified, prefer direct + // ownership (the caller's own data for that profile); otherwise fall through + // to the share-gate (admits an active share recipient). profile_id is a + // free-form string at create time and may not map to a Profile document. + let owner = match &query.profile_id { + Some(pid) => { + let has_own = repo + .find_by_user_filtered(&claims.sub, None, Some(pid)) + .await + .map(|v| !v.is_empty()) + .unwrap_or(false); + if has_own { + claims.sub.clone() + } else { + crate::handlers::profile_share::authorize_profile_read(&state, &claims, pid).await? + } + } + None => claims.sub.clone(), + }; + match repo - .find_by_user_filtered(&claims.sub, query.status.as_deref()) + .find_by_user_filtered(&owner, query.status.as_deref(), query.profile_id.as_deref()) .await { Ok(appointments) => { let resp: Vec = appointments.into_iter().map(Into::into).collect(); Ok(Json(resp)) } - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + Err(e) => { + tracing::error!("list_appointments failed: {}", e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )) + } } } pub async fn get_appointment( State(state): State, - Extension(_claims): Extension, + Extension(claims): Extension, Path(id): Path, -) -> Result, StatusCode> { +) -> Result, (StatusCode, Json)> { let database = state.db.get_database(); let repo = AppointmentRepository::new(database.collection("appointments")); - match repo.find_by_appointment_id(&id).await { - Ok(Some(appt)) => Ok(Json(appt.into())), - Ok(None) => Err(StatusCode::NOT_FOUND), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + let appt = match repo.find_by_appointment_id(&id).await { + Ok(Some(a)) => a, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not found" })), + )); + } + Err(e) => { + tracing::error!("get_appointment failed: {}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )); + } + }; + // Authorization: direct owner (user_id == caller) OR share-gate on the + // appointment's profile (admits an active share recipient). + if appt.user_id != claims.sub { + crate::handlers::profile_share::authorize_profile_read(&state, &claims, &appt.profile_id) + .await?; } + Ok(Json(appt.into())) } pub async fn update_appointment( State(state): State, - Extension(_claims): Extension, + Extension(claims): Extension, Path(id): Path, Json(req): Json, -) -> Result, StatusCode> { +) -> Result, (StatusCode, Json)> { let database = state.db.get_database(); let repo = AppointmentRepository::new(database.collection("appointments")); + // Authorization: owner-only (recipients are read-only). 404 on mismatch to + // avoid leaking existence. + let existing = match repo.find_by_appointment_id(&id).await { + Ok(Some(a)) => a, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not found" })), + )); + } + Err(e) => { + tracing::error!("update_appointment lookup failed: {}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )); + } + }; + if existing.user_id != claims.sub { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not found" })), + )); + } + match repo.update_by_appointment_id(&id, req).await { Ok(Some(appt)) => Ok(Json(appt.into())), - Ok(None) => Err(StatusCode::NOT_FOUND), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + Ok(None) => Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not found" })), + )), + Err(e) => { + tracing::error!("update_appointment failed: {}", e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )) + } } } pub async fn delete_appointment( State(state): State, - Extension(_claims): Extension, + Extension(claims): Extension, Path(id): Path, -) -> Result { +) -> Result)> { let database = state.db.get_database(); let repo = AppointmentRepository::new(database.collection("appointments")); + // Authorization: owner-only. + let existing = match repo.find_by_appointment_id(&id).await { + Ok(Some(a)) => a, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not found" })), + )); + } + Err(e) => { + tracing::error!("delete_appointment lookup failed: {}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )); + } + }; + if existing.user_id != claims.sub { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not found" })), + )); + } + match repo.delete_by_appointment_id(&id).await { Ok(true) => Ok(StatusCode::NO_CONTENT), - Ok(false) => Err(StatusCode::NOT_FOUND), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + Ok(false) => Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not found" })), + )), + Err(e) => { + tracing::error!("delete_appointment failed: {}", e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )) + } } } diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs index cbe8836..1b7a2d4 100644 --- a/backend/src/handlers/auth.rs +++ b/backend/src/handlers/auth.rs @@ -27,6 +27,20 @@ pub struct RegisterRequest { /// Wrapped DEK (base64 ciphertext) — encrypted under the recovery KEK pub recovery_wrapped_dek: Option, pub recovery_wrapped_dek_iv: Option, + /// Account X25519 identity public key (base64 raw). Plaintext. + pub identity_public_key: Option, + /// Account X25519 identity private key, wrapped under the account DEK. + pub identity_private_key_wrapped: Option, + pub identity_private_key_wrapped_iv: Option, + /// Default-profile display name (opaque, encrypted under the profile DEK) + /// and the profile DEK wrapped under the account DEK. When present, the + /// register flow auto-creates a "self" profile; when absent, no profile is + /// created and the client is expected to POST /api/profiles on first run. + pub default_profile_name_data: Option, + pub default_profile_name_iv: Option, + pub default_profile_name_auth_tag: Option, + pub default_wrapped_profile_dek: Option, + pub default_wrapped_profile_dek_iv: Option, } #[derive(Debug, Serialize)] @@ -40,6 +54,12 @@ pub struct AuthResponse { /// user has no wrapped DEK (legacy/pre-recovery accounts). pub wrapped_dek: Option, pub wrapped_dek_iv: Option, + /// Account X25519 identity keypair. The public key is plaintext; the + /// private key is an opaque ciphertext blob wrapped under the account DEK. + /// Absent for accounts that predate the identity-keypair feature. + pub identity_public_key: Option, + pub identity_private_key_wrapped: Option, + pub identity_private_key_wrapped_iv: Option, } pub async fn register( @@ -108,6 +128,11 @@ pub async fn register( user.recovery_wrapped_dek = req.recovery_wrapped_dek.clone(); user.recovery_wrapped_dek_iv = req.recovery_wrapped_dek_iv.clone(); user.recovery_enabled = req.recovery_wrapped_dek.is_some(); + // Store the X25519 identity keypair (public plaintext, private wrapped + // under the account DEK — server stores verbatim, cannot decrypt). + user.identity_public_key = req.identity_public_key; + user.identity_private_key_wrapped = req.identity_private_key_wrapped; + user.identity_private_key_wrapped_iv = req.identity_private_key_wrapped_iv; // Get token_version before saving let token_version = user.token_version; @@ -129,17 +154,29 @@ pub async fn register( .await; } - // Auto-create a default profile for the new user. The profile_id is - // deterministic (profile_) and is the contract the frontend - // uses for medication creation. Best-effort: registration still - // succeeds if this fails (GET /profiles/me lazily creates one). - let database = state.db.get_database(); - let profile_repo = - crate::models::profile::ProfileRepository::new(database.collection("profiles")); - let profile = - crate::handlers::profile::build_default_profile(&id.to_string(), &req.username); - if let Err(e) = profile_repo.create(&profile).await { - tracing::warn!("Failed to auto-create profile for {}: {}", id, e); + // Auto-create the default "self" profile for the new user, but only + // when the client provided a wrapped profile DEK. The profile_id is + // deterministic (profile_). Best-effort: registration still + // succeeds if this fails or if no wrapped DEK was provided (the + // client creates the profile via POST /api/profiles on first run). + if let (Some(wrapped_dek), Some(wrapped_dek_iv)) = ( + req.default_wrapped_profile_dek.as_ref(), + req.default_wrapped_profile_dek_iv.as_ref(), + ) { + let database = state.db.get_database(); + let profile_repo = + crate::models::profile::ProfileRepository::new(database.collection("profiles")); + let profile = crate::handlers::profile::build_default_profile( + &id.to_string(), + wrapped_dek, + wrapped_dek_iv, + req.default_profile_name_data.as_deref().unwrap_or(""), + req.default_profile_name_iv.as_deref().unwrap_or(""), + req.default_profile_name_auth_tag.as_deref().unwrap_or(""), + ); + if let Err(e) = profile_repo.create(&profile).await { + tracing::warn!("Failed to auto-create profile for {}: {}", id, e); + } } id @@ -203,6 +240,9 @@ pub async fn register( username: user.username.clone(), wrapped_dek: user.wrapped_dek.clone(), wrapped_dek_iv: user.wrapped_dek_iv.clone(), + identity_public_key: user.identity_public_key.clone(), + identity_private_key_wrapped: user.identity_private_key_wrapped.clone(), + identity_private_key_wrapped_iv: user.identity_private_key_wrapped_iv.clone(), }; (StatusCode::CREATED, Json(response)).into_response() @@ -413,6 +453,9 @@ pub async fn login( username: user.username.clone(), wrapped_dek: user.wrapped_dek.clone(), wrapped_dek_iv: user.wrapped_dek_iv.clone(), + identity_public_key: user.identity_public_key.clone(), + identity_private_key_wrapped: user.identity_private_key_wrapped.clone(), + identity_private_key_wrapped_iv: user.identity_private_key_wrapped_iv.clone(), }; (StatusCode::OK, Json(response)).into_response() diff --git a/backend/src/handlers/health_stats.rs b/backend/src/handlers/health_stats.rs index ca902cd..5397d8e 100644 --- a/backend/src/handlers/health_stats.rs +++ b/backend/src/handlers/health_stats.rs @@ -3,7 +3,7 @@ use crate::config::AppState; use crate::models::health_stats::{HealthStatResponse, HealthStatistic}; use crate::models::medication::EncryptedFieldWire; use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, Extension, Json, @@ -14,9 +14,16 @@ use serde::Deserialize; #[derive(Debug, Deserialize)] pub struct CreateHealthStatRequest { pub encrypted_data: EncryptedFieldWire, + /// Which profile this stat belongs to. + pub profile_id: String, pub recorded_at: Option, } +#[derive(Debug, Deserialize, Default)] +pub struct ListHealthStatsQuery { + pub profile_id: Option, +} + #[derive(Debug, Deserialize)] pub struct UpdateHealthStatRequest { pub encrypted_data: EncryptedFieldWire, @@ -41,6 +48,7 @@ pub async fn create_health_stat( let stat = HealthStatistic { id: None, user_id: claims.sub.clone(), + profile_id: req.profile_id, encrypted_data: req.encrypted_data, recorded_at: req .recorded_at @@ -70,6 +78,7 @@ pub async fn create_health_stat( pub async fn list_health_stats( State(state): State, Extension(claims): Extension, + Query(query): Query, ) -> impl IntoResponse { let repo = match state.health_stats_repo.as_ref() { Some(r) => r, @@ -81,7 +90,34 @@ pub async fn list_health_stats( .into_response() } }; - match repo.find_by_user(&claims.sub).await { + // Resolve the effective owner. If a profile_id is specified, prefer direct + // ownership (the caller's own data for that profile); otherwise fall through + // to the share-gate (admits an active share recipient). profile_id is a + // free-form string at create time and may not map to a Profile document. + let owner = match &query.profile_id { + Some(pid) => { + let has_own = repo + .find_by_user_filtered(&claims.sub, Some(pid)) + .await + .map(|v| !v.is_empty()) + .unwrap_or(false); + if has_own { + claims.sub.clone() + } else { + match crate::handlers::profile_share::authorize_profile_read(&state, &claims, pid) + .await + { + Ok(o) => o, + Err(resp) => return resp.into_response(), + } + } + } + None => claims.sub.clone(), + }; + match repo + .find_by_user_filtered(&owner, query.profile_id.as_deref()) + .await + { Ok(stats) => { let resp: Vec = stats.into_iter().map(Into::into).collect(); (StatusCode::OK, Json(resp)).into_response() @@ -122,10 +158,21 @@ pub async fn get_health_stat( match repo.find_by_id(&object_id).await { Ok(Some(stat)) => { - if stat.user_id != claims.sub { - return (StatusCode::FORBIDDEN, "Access denied").into_response(); + // Authorization: direct owner (user_id == caller) OR share-gate on + // the stat's profile (admits an active share recipient). + if stat.user_id == claims.sub { + return (StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response(); + } + match crate::handlers::profile_share::authorize_profile_read( + &state, + &claims, + &stat.profile_id, + ) + .await + { + Ok(_) => (StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response(), + Err(resp) => resp.into_response(), } - (StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response() } Ok(None) => ( StatusCode::NOT_FOUND, diff --git a/backend/src/handlers/medications.rs b/backend/src/handlers/medications.rs index 0d70c0e..c6bf0c5 100644 --- a/backend/src/handlers/medications.rs +++ b/backend/src/handlers/medications.rs @@ -64,70 +64,193 @@ pub async fn list_medications( State(state): State, Extension(claims): Extension, Query(query): Query, -) -> Result>, StatusCode> { +) -> Result>, (StatusCode, Json)> { let database = state.db.get_database(); let repo = MedicationRepository::new(database.collection("medications")); - // Honor the active filter (and optional profile_id) when provided. + // Resolve the effective owner. If the caller specifies a profile_id, prefer + // direct ownership (the caller's own data for that profile); if they don't + // own it, fall through to the share-gate (admits an active share recipient + // and returns the profile's owner userId to query as). Without a profile_id, + // return the caller's own data across all their profiles. + // + // The direct-ownership-first check matters because a medication's profile_id + // is a free-form string at create time and may not map to a Profile document + // (legacy/test data) — the gate would 404 on those even for the real owner. + let owner = match &query.profile_id { + Some(pid) => { + let has_own = repo + .find_by_user_and_profile(&claims.sub, pid) + .await + .map(|v| !v.is_empty()) + .unwrap_or(false); + if has_own { + claims.sub.clone() + } else { + crate::handlers::profile_share::authorize_profile_read(&state, &claims, pid).await? + } + } + None => claims.sub.clone(), + }; + match repo - .find_by_user_filtered(&claims.sub, query.active, query.profile_id.as_deref()) + .find_by_user_filtered(&owner, query.active, query.profile_id.as_deref()) .await { Ok(medications) => { let resp: Vec = medications.into_iter().map(Into::into).collect(); Ok(Json(resp)) } - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + Err(e) => { + tracing::error!("list_medications failed: {}", e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )) + } } } pub async fn get_medication( State(state): State, - Extension(_claims): Extension, + Extension(claims): Extension, Path(id): Path, -) -> Result, StatusCode> { +) -> Result, (StatusCode, Json)> { let database = state.db.get_database(); let repo = MedicationRepository::new(database.collection("medications")); // The path param is the application-level medication_id (a UUID), not the // Mongo _id, so look it up directly instead of parsing as ObjectId. - match repo.find_by_medication_id(&id).await { - Ok(Some(medication)) => Ok(Json(medication.into())), - Ok(None) => Err(StatusCode::NOT_FOUND), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + let medication = match repo.find_by_medication_id(&id).await { + Ok(Some(m)) => m, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not found" })), + )); + } + Err(e) => { + tracing::error!("get_medication failed: {}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )); + } + }; + // Authorization: admit the direct owner (user_id == caller) OR, if not, + // fall through to the share-gate on the medication's profile (admits an + // active share recipient). The direct-owner check preserves the original + // ownership model for medications whose profile_id may not map to a real + // Profile document (e.g. legacy/test data). + if medication.user_id != claims.sub { + crate::handlers::profile_share::authorize_profile_read( + &state, + &claims, + &medication.profile_id, + ) + .await?; } + Ok(Json(medication.into())) } pub async fn update_medication( State(state): State, - Extension(_claims): Extension, + Extension(claims): Extension, Path(id): Path, Json(req): Json, -) -> Result, StatusCode> { +) -> Result, (StatusCode, Json)> { let database = state.db.get_database(); let repo = MedicationRepository::new(database.collection("medications")); - // Look up by medication_id (UUID) — the path param is not a Mongo ObjectId. + // Authorization: look up first, confirm the caller owns the medication + // (writes are owner-only — recipients are read-only per the ADR). Use 404 + // (not 403) on a mismatch to avoid leaking the existence of other users' + // records. + let existing = match repo.find_by_medication_id(&id).await { + Ok(Some(m)) => m, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not found" })), + )); + } + Err(e) => { + tracing::error!("update_medication lookup failed: {}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )); + } + }; + if existing.user_id != claims.sub { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not found" })), + )); + } + match repo.update_by_medication_id(&id, req).await { Ok(Some(medication)) => Ok(Json(medication.into())), - Ok(None) => Err(StatusCode::NOT_FOUND), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + Ok(None) => Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not found" })), + )), + Err(e) => { + tracing::error!("update_medication failed: {}", e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )) + } } } pub async fn delete_medication( State(state): State, - Extension(_claims): Extension, + Extension(claims): Extension, Path(id): Path, -) -> Result { +) -> Result)> { let database = state.db.get_database(); let repo = MedicationRepository::new(database.collection("medications")); - // Look up by medication_id (UUID), not Mongo _id. + // Authorization: owner-only (see update_medication). Look up first to + // avoid leaking existence via the deleted_count. + let existing = match repo.find_by_medication_id(&id).await { + Ok(Some(m)) => m, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not found" })), + )); + } + Err(e) => { + tracing::error!("delete_medication lookup failed: {}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )); + } + }; + if existing.user_id != claims.sub { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not found" })), + )); + } + match repo.delete_by_medication_id(&id).await { Ok(true) => Ok(StatusCode::NO_CONTENT), - Ok(false) => Err(StatusCode::NOT_FOUND), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + Ok(false) => Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not found" })), + )), + Err(e) => { + tracing::error!("delete_medication failed: {}", e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )) + } } } @@ -136,8 +259,34 @@ pub async fn log_dose( Extension(claims): Extension, Path(id): Path, Json(req): Json, -) -> Result<(StatusCode, Json), StatusCode> { +) -> Result<(StatusCode, Json), (StatusCode, Json)> { let database = state.db.get_database(); + let repo = MedicationRepository::new(database.collection("medications")); + + // Authorization: confirm the caller owns the medication before logging a + // dose against it (otherwise a user could skew another's adherence stats). + let med = match repo.find_by_medication_id(&id).await { + Ok(Some(m)) => m, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not found" })), + )); + } + Err(e) => { + tracing::error!("log_dose lookup failed: {}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )); + } + }; + if med.user_id != claims.sub { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not found" })), + )); + } let now = SystemTime::now(); @@ -155,7 +304,13 @@ pub async fn log_dose( .collection::("medication_doses") .insert_one(&dose, None) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|e| { + tracing::error!("log_dose insert failed: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + ) + })?; // Populate the generated _id so the caller gets the persisted dose back. dose.id = result.inserted_id.as_object_id(); @@ -165,9 +320,10 @@ pub async fn log_dose( pub async fn get_adherence( State(state): State, - Extension(_claims): Extension, + Extension(claims): Extension, Path(id): Path, -) -> Result, StatusCode> { +) -> Result, (StatusCode, Json)> +{ use mongodb::bson::{doc, DateTime}; const PERIOD_DAYS: i64 = 30; @@ -176,6 +332,28 @@ pub async fn get_adherence( let doses: mongodb::Collection = database.collection("medication_doses"); let med_repo = MedicationRepository::new(database.collection("medications")); + // Authorization: owner-only. Look up the medication and confirm ownership + // before computing/returning adherence (which leaks dose history). + let medication = match med_repo.find_by_medication_id(&id).await { + Ok(Some(m)) => { + if m.user_id != claims.sub { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not found" })), + )); + } + Some(m) + } + Ok(None) => None, + Err(e) => { + tracing::error!("get_adherence lookup failed: {}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )); + } + }; + // Look at doses logged in the last PERIOD_DAYS days for this medication. let since = DateTime::from_system_time( SystemTime::now() - std::time::Duration::from_secs(PERIOD_DAYS as u64 * 86400), @@ -188,19 +366,25 @@ pub async fn get_adherence( let total_logged = doses .count_documents(filter.clone(), None) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|e| { + tracing::error!("get_adherence count failed: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + ) + })?; let taken_filter = doc! { "$and": [filter, doc! { "taken": true }] }; let taken = doses .count_documents(taken_filter, None) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - // Fetch the medication to check for a dose schedule. - let medication = med_repo - .find_by_medication_id(&id) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|e| { + tracing::error!("get_adherence taken count failed: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + ) + })?; let (scheduled_doses, total_doses, missed_doses, rate) = match medication.and_then(|m| m.dose_schedule) { diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 521d0b1..68ac996 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -4,10 +4,9 @@ pub mod health; pub mod health_stats; pub mod interactions; pub mod medications; -pub mod permissions; pub mod profile; +pub mod profile_share; pub mod sessions; -pub mod shares; pub mod users; // Re-export commonly used handler functions @@ -24,10 +23,12 @@ pub use medications::{ create_medication, delete_medication, get_adherence, get_medication, list_medications, log_dose, update_medication, }; -pub use permissions::check_permission; -pub use profile::{get_my_profile, update_my_profile}; -pub use sessions::{get_sessions, revoke_all_sessions, revoke_session}; -pub use shares::{create_share, delete_share, list_shares, update_share}; -pub use users::{ - change_password, delete_account, get_profile, get_settings, update_profile, update_settings, +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, +}; +pub use sessions::{get_sessions, revoke_all_sessions, revoke_session}; +pub use users::{ + change_password, delete_account, get_account, get_settings, update_account, update_settings, }; diff --git a/backend/src/handlers/permissions.rs b/backend/src/handlers/permissions.rs deleted file mode 100644 index 6a4c751..0000000 --- a/backend/src/handlers/permissions.rs +++ /dev/null @@ -1,62 +0,0 @@ -use axum::{ - extract::{Query, State}, - http::StatusCode, - response::IntoResponse, - Extension, Json, -}; -use serde::{Deserialize, Serialize}; - -use crate::{auth::jwt::Claims, config::AppState}; - -#[derive(Debug, Deserialize)] -pub struct CheckPermissionQuery { - pub resource_type: String, - pub resource_id: String, - pub permission: String, -} - -#[derive(Debug, Serialize)] -pub struct PermissionCheckResponse { - pub has_permission: bool, - pub resource_type: String, - pub resource_id: String, - pub permission: String, -} - -pub async fn check_permission( - State(state): State, - Query(params): Query, - Extension(claims): Extension, -) -> impl IntoResponse { - let has_permission = match state - .db - .check_user_permission( - &claims.sub, - ¶ms.resource_type, - ¶ms.resource_id, - ¶ms.permission, - ) - .await - { - Ok(result) => result, - Err(e) => { - tracing::error!("Failed to check permission: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "failed to check permission" - })), - ) - .into_response(); - } - }; - - let response = PermissionCheckResponse { - has_permission, - resource_type: params.resource_type, - resource_id: params.resource_id, - permission: params.permission, - }; - - (StatusCode::OK, Json(response)).into_response() -} diff --git a/backend/src/handlers/profile.rs b/backend/src/handlers/profile.rs index ea3d37e..c3edb21 100644 --- a/backend/src/handlers/profile.rs +++ b/backend/src/handlers/profile.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Extension, State}, + extract::{Extension, Path, State}, http::StatusCode, Json, }; @@ -12,17 +12,21 @@ use crate::{ models::profile::{Profile, ProfileRepository}, }; -/// The profile as exposed to clients. The display name is returned as an opaque -/// client-encrypted blob (the server cannot read it). +/// The profile as exposed to clients. Display name + wrapped profile DEK are +/// returned as opaque client-encrypted blobs (the server cannot read either). #[derive(Debug, Serialize)] pub struct ProfileResponse { pub profile_id: String, - pub user_id: String, - /// Opaque client-encrypted display name. + pub owner_account_id: String, pub name_data: String, pub name_iv: String, + pub kind: String, + pub relationship: String, pub role: String, pub permissions: Vec, + /// Profile DEK wrapped under the owner's account DEK (opaque). + pub wrapped_profile_dek: String, + pub wrapped_profile_dek_iv: String, pub created_at: DateTime, pub updated_at: DateTime, } @@ -31,98 +35,212 @@ impl From for ProfileResponse { fn from(p: Profile) -> Self { Self { profile_id: p.profile_id, - user_id: p.user_id, + owner_account_id: p.owner_account_id, name_data: p.name, name_iv: p.name_iv, + kind: p.kind, + relationship: p.relationship, role: p.role, permissions: p.permissions, + wrapped_profile_dek: p.wrapped_profile_dek, + wrapped_profile_dek_iv: p.wrapped_profile_dek_iv, created_at: p.created_at, updated_at: p.updated_at, } } } -/// Create the default "patient" profile for a freshly registered user. Called -/// from `register`. The profile_id is deterministic: `profile_` — this -/// is the contract the frontend relies on for medication creation. The name is -/// empty (not encrypted) because the server has no encryption key; the client -/// sets it via PUT /profiles/me after deriving its key. -pub fn build_default_profile(user_id: &str, _name: &str) -> Profile { +/// Build a default "self" profile for a freshly registered user. Called from +/// `register` when the client provides a wrapped profile DEK for the default +/// profile. The server has no encryption key, so the name blob and wrapped +/// profile DEK are taken verbatim from the caller (the client generated the +/// profile DEK and wrapped it under the account DEK). +pub fn build_default_profile( + owner_account_id: &str, + wrapped_profile_dek: &str, + wrapped_profile_dek_iv: &str, + name_data: &str, + name_iv: &str, + name_auth_tag: &str, +) -> Profile { let now = DateTime::now(); Profile { id: None, - profile_id: format!("profile_{user_id}"), - user_id: user_id.to_string(), + // profile_ keeps the deterministic-id contract the frontend + // used pre-A2 for the self profile. + profile_id: format!("profile_{owner_account_id}"), + owner_account_id: owner_account_id.to_string(), + user_id: owner_account_id.to_string(), family_id: None, - name: String::new(), - name_iv: String::new(), - name_auth_tag: String::new(), + name: name_data.to_string(), + name_iv: name_iv.to_string(), + name_auth_tag: name_auth_tag.to_string(), + kind: "human".to_string(), + relationship: "self".to_string(), role: "patient".to_string(), permissions: vec!["read:self".to_string(), "write:self".to_string()], + wrapped_profile_dek: wrapped_profile_dek.to_string(), + wrapped_profile_dek_iv: wrapped_profile_dek_iv.to_string(), created_at: now, updated_at: now, } } -/// PUT /profiles/me body — the client sends the encrypted name blob. +fn profile_repo(state: &AppState) -> ProfileRepository { + ProfileRepository::new(state.db.get_database().collection("profiles")) +} + +// --------------------------------------------------------------------------- +// GET /api/profiles — list all profiles owned by the current account +// --------------------------------------------------------------------------- + +pub async fn list_profiles( + State(state): State, + Extension(claims): Extension, +) -> Result>, (StatusCode, Json)> { + let repo = profile_repo(&state); + match repo.find_all_by_owner(&claims.sub).await { + Ok(profiles) => { + let resp: Vec = profiles.into_iter().map(Into::into).collect(); + Ok(Json(resp)) + } + Err(e) => { + tracing::error!("Profile list failed: {}", e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )) + } + } +} + +// --------------------------------------------------------------------------- +// POST /api/profiles — create a new profile (child, pet, ...). +// The client generates the profile DEK, wraps it under the account DEK, and +// sends the wrapped blob + the encrypted display name. +// --------------------------------------------------------------------------- + #[derive(Debug, Deserialize)] -pub struct UpdateProfileNameRequest { +pub struct CreateProfileRequest { + /// Optional explicit profile id; defaults to a fresh UUID if absent. + #[serde(default)] + pub profile_id: Option, + #[serde(default = "default_kind_value")] + pub kind: String, + #[serde(default)] + pub relationship: String, pub name_data: String, pub name_iv: String, #[serde(default)] pub name_auth_tag: String, + /// Profile DEK wrapped under the account DEK (opaque ciphertext). + pub wrapped_profile_dek: String, + pub wrapped_profile_dek_iv: String, } -/// GET /api/profiles/me — the current user's profile. If for some reason the -/// auto-created profile is missing, lazily create it. -pub async fn get_my_profile( +fn default_kind_value() -> String { + "human".to_string() +} + +pub async fn create_profile( State(state): State, Extension(claims): Extension, -) -> Result, (StatusCode, Json)> { - let database = state.db.get_database(); - let repo = ProfileRepository::new(database.collection("profiles")); - - let profile = match repo.find_by_user_id(&claims.sub).await { - Ok(Some(p)) => p, - Ok(None) => { - // Lazily create if missing (e.g. users registered before this code shipped). - let p = build_default_profile(&claims.sub, &claims.sub); - if let Err(e) = repo.create(&p).await { - tracing::error!("Failed to lazily create profile: {}", e); - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": "failed to load profile" })), - )); - } - p - } - Err(e) => { - tracing::error!("Profile lookup failed: {}", e); - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": "database error" })), - )); - } + Json(req): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + let now = DateTime::now(); + let profile = Profile { + id: None, + profile_id: req + .profile_id + .unwrap_or_else(|| format!("p_{}", uuid::Uuid::new_v4())), + owner_account_id: claims.sub.clone(), + user_id: claims.sub.clone(), + family_id: None, + name: req.name_data, + name_iv: req.name_iv, + name_auth_tag: req.name_auth_tag, + kind: req.kind, + relationship: req.relationship, + role: "patient".to_string(), + permissions: vec!["read:self".to_string(), "write:self".to_string()], + wrapped_profile_dek: req.wrapped_profile_dek, + wrapped_profile_dek_iv: req.wrapped_profile_dek_iv, + created_at: now, + updated_at: now, }; - Ok(Json(ProfileResponse::from(profile))) + let repo = profile_repo(&state); + if let Err(e) = repo.create(&profile).await { + tracing::error!("Profile create failed: {}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )); + } + Ok((StatusCode::CREATED, Json(ProfileResponse::from(profile)))) } -/// PUT /api/profiles/me — update the profile's encrypted display-name blob. -pub async fn update_my_profile( +// --------------------------------------------------------------------------- +// GET /api/profiles/:id — fetch one owned profile +// --------------------------------------------------------------------------- + +pub async fn get_profile( State(state): State, Extension(claims): Extension, - Json(req): Json, + Path(profile_id): Path, ) -> Result, (StatusCode, Json)> { - let database = state.db.get_database(); - let repo = ProfileRepository::new(database.collection("profiles")); - + let repo = profile_repo(&state); match repo - .update_encrypted_name( + .find_by_profile_id_owned(&profile_id, &claims.sub) + .await + { + Ok(Some(p)) => Ok(Json(ProfileResponse::from(p))), + Ok(None) => Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "profile not found" })), + )), + Err(e) => { + tracing::error!("Profile lookup failed: {}", e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )) + } + } +} + +// --------------------------------------------------------------------------- +// PUT /api/profiles/:id — update an owned profile's mutable fields +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct UpdateProfileRequest { + pub name_data: String, + pub name_iv: String, + #[serde(default)] + pub name_auth_tag: String, + #[serde(default = "default_kind_value")] + pub kind: String, + #[serde(default)] + pub relationship: String, +} + +pub async fn update_profile( + State(state): State, + Extension(claims): Extension, + Path(profile_id): Path, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let repo = profile_repo(&state); + match repo + .update_profile( + &profile_id, &claims.sub, &req.name_data, &req.name_iv, &req.name_auth_tag, + &req.kind, + &req.relationship, ) .await { @@ -140,3 +258,29 @@ pub async fn update_my_profile( } } } + +// --------------------------------------------------------------------------- +// DELETE /api/profiles/:id — delete an owned profile +// --------------------------------------------------------------------------- + +pub async fn delete_profile( + State(state): State, + Extension(claims): Extension, + Path(profile_id): Path, +) -> Result)> { + let repo = profile_repo(&state); + match repo.delete_profile(&profile_id, &claims.sub).await { + Ok(true) => Ok(StatusCode::NO_CONTENT), + Ok(false) => Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "profile not found" })), + )), + Err(e) => { + tracing::error!("Profile delete failed: {}", e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )) + } + } +} diff --git a/backend/src/handlers/profile_share.rs b/backend/src/handlers/profile_share.rs new file mode 100644 index 0000000..3895fb1 --- /dev/null +++ b/backend/src/handlers/profile_share.rs @@ -0,0 +1,708 @@ +//! Profile sharing (Phase B). +//! +//! Implements the X25519 envelope: an owner wraps a profile DEK to a +//! recipient's identity public key via ECDH, storing the opaque wrapped blob +//! keyed by (profileId, recipientUserId). Recipients unwrap with their own +//! identity private key. The server never sees the profile DEK. +//! +//! Also provides `authorize_profile_read` — the share-gate used by the data +//! handlers (medications, appointments, health stats) to admit recipients. + +use axum::{ + extract::{Extension, Path, Query, State}, + http::StatusCode, + Json, +}; +use mongodb::bson::{oid::ObjectId, DateTime}; +use serde::{Deserialize, Serialize}; + +use crate::{ + auth::jwt::Claims, + config::AppState, + models::profile::ProfileRepository, + models::profile_share::{ProfileShare, ProfileShareRepository}, +}; + +fn profile_repo(state: &AppState) -> ProfileRepository { + ProfileRepository::new(state.db.get_database().collection("profiles")) +} + +fn share_repo(state: &AppState) -> ProfileShareRepository { + ProfileShareRepository::new(state.db.get_database().collection("profile_shares")) +} + +/// The shared profile as seen by the recipient — the profile's metadata +/// (display name blob, kind, relationship) plus the per-share ECDH-wrapped DEK +/// the recipient unwraps with their identity private key. The server returns +/// the wrapped DEK from the share record verbatim and cannot read it. +#[derive(Debug, Serialize)] +pub struct SharedProfileResponse { + pub profile_id: String, + pub owner_account_id: String, + pub name_data: String, + pub name_iv: String, + pub kind: String, + pub relationship: String, + /// Per-share ephemeral X25519 public key (base64 raw). The recipient + /// combines it with their identity private key to derive the wrap key. + pub ephemeral_public_key: String, + /// Profile DEK wrapped under the ECDH-derived key (opaque). + pub wrapped_profile_dek: String, + pub wrapped_profile_dek_iv: String, + pub permissions: Vec, + pub created_at: DateTime, +} + +/// A share as listed by the owner (no wrapped DEK on this view — it's only +/// useful to the recipient; the owner already has the profile DEK directly). +#[derive(Debug, Serialize)] +pub struct ShareListingResponse { + pub profile_id: String, + pub recipient_user_id: String, + pub recipient_email: String, + pub permissions: Vec, + pub created_at: DateTime, + pub active: bool, +} + +// --------------------------------------------------------------------------- +// Share-gate: resolve the owner userId for a profile the caller may read. +// Used by the data handlers to admit recipients. +// --------------------------------------------------------------------------- + +/// If the caller (claims.sub) owns the profile, returns Ok(owner_user_id). +/// Otherwise checks for an active share; if present, returns Ok(owner_user_id) +/// (the profile's owner — recipients read "as the owner" for that profile). +/// Returns Err(404 response) if the caller has no read access. +pub async fn authorize_profile_read( + state: &AppState, + claims: &Claims, + profile_id: &str, +) -> Result)> { + let profiles = profile_repo(state); + // Owner path. + if let Ok(Some(owned)) = profiles + .find_by_profile_id_owned(profile_id, &claims.sub) + .await + { + return Ok(owned.owner_account_id); + } + // Recipient path: an active (unexpired, not-revoked) share for this pair. + let shares = share_repo(state); + match shares.find_active(profile_id, &claims.sub).await { + Ok(Some(share)) => Ok(share.owner_user_id), + Ok(None) => Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "profile not found" })), + )), + Err(e) => { + tracing::error!("Share lookup failed for {}: {}", profile_id, e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )) + } + } +} + +// --------------------------------------------------------------------------- +// POST /api/profiles/:id/shares — owner shares a profile to a recipient. +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct CreateShareRequest { + /// The recipient, identified by email. + pub recipient_email: String, + /// Ephemeral X25519 public key (base64 raw) generated by the owner's + /// client for this share. + pub ephemeral_public_key: String, + /// Profile DEK wrapped (client-side, via ECDH) to the recipient. Opaque. + pub wrapped_profile_dek: String, + pub wrapped_profile_dek_iv: String, + #[serde(default = "default_read_permissions")] + pub permissions: Vec, + /// Optional RFC-3339 expiry; null/absent = never expires. + pub expires_at: Option, +} + +fn default_read_permissions() -> Vec { + vec!["read".to_string()] +} + +pub async fn create_share( + State(state): State, + Extension(claims): Extension, + Path(profile_id): Path, + Json(req): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + // Verify the caller owns the profile. + 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!("Profile lookup in create_share failed: {}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )); + } + }; + + // Resolve recipient by email. + let recipient = match state.db.find_user_by_email(&req.recipient_email).await { + Ok(Some(u)) => u, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "recipient not found" })), + )); + } + Err(e) => { + tracing::error!("Recipient lookup failed: {}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )); + } + }; + let recipient_id = match recipient.id { + Some(id) => id.to_string(), + None => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "invalid recipient state" })), + )); + } + }; + // Can't share to an account without an identity public key (pre-A1). + if recipient + .identity_public_key + .as_deref() + .unwrap_or("") + .is_empty() + { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": "recipient has no identity public key (account predates the keypair feature)" + })), + )); + } + // Don't share to yourself. + if recipient_id == claims.sub { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "cannot share a profile with yourself" })), + )); + } + + let expires_at = match req.expires_at.as_deref() { + Some(s) => match DateTime::parse_rfc3339_str(s) { + Ok(dt) => Some(dt), + Err(_) => { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "invalid expires_at (expected RFC 3339)" })), + )); + } + }, + None => None, + }; + + let now = DateTime::now(); + let share = ProfileShare { + id: None, + profile_id: profile_id.clone(), + owner_user_id: owner.clone(), + recipient_user_id: recipient_id.clone(), + ephemeral_public_key: req.ephemeral_public_key, + wrapped_profile_dek: req.wrapped_profile_dek, + wrapped_profile_dek_iv: req.wrapped_profile_dek_iv, + permissions: req.permissions.clone(), + expires_at, + created_at: now, + active: true, + }; + + let shares = share_repo(&state); + if let Err(e) = shares.upsert(&share).await { + tracing::error!("Share create failed: {}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )); + } + + Ok(( + StatusCode::CREATED, + Json(ShareListingResponse { + profile_id, + recipient_user_id: recipient_id, + recipient_email: req.recipient_email, + permissions: req.permissions, + created_at: now, + active: true, + }), + )) +} + +// --------------------------------------------------------------------------- +// GET /api/profiles/:id/shares — owner lists who they've shared a profile with. +// --------------------------------------------------------------------------- + +pub async fn list_shares( + State(state): State, + Extension(claims): Extension, + Path(profile_id): Path, +) -> Result>, (StatusCode, Json)> { + // Owner-only: confirm ownership. + let profiles = profile_repo(&state); + match profiles + .find_by_profile_id_owned(&profile_id, &claims.sub) + .await + { + Ok(Some(_)) => {} + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "profile not found" })), + )); + } + Err(e) => { + tracing::error!("Profile lookup in list_shares failed: {}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )); + } + } + + let shares = share_repo(&state); + let rows = match shares.find_for_profile(&profile_id).await { + Ok(rows) => rows, + Err(e) => { + tracing::error!("Share list failed: {}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )); + } + }; + + // Resolve each recipient's email for display. + let mut out = Vec::with_capacity(rows.len()); + for s in rows { + let email = match ObjectId::parse_str(&s.recipient_user_id) { + Ok(oid) => match state.db.find_user_by_id(&oid).await { + Ok(Some(u)) => u.email, + _ => String::new(), + }, + Err(_) => String::new(), + }; + out.push(ShareListingResponse { + profile_id: s.profile_id, + recipient_user_id: s.recipient_user_id, + recipient_email: email, + permissions: s.permissions, + created_at: s.created_at, + active: s.active, + }); + } + Ok(Json(out)) +} + +// --------------------------------------------------------------------------- +// DELETE /api/profiles/:id/shares/:recipient — soft revoke (hard delete). +// --------------------------------------------------------------------------- + +pub async fn delete_share( + State(state): State, + Extension(claims): Extension, + Path((profile_id, recipient_user_id)): Path<(String, String)>, +) -> Result)> { + // Owner-only: confirm ownership. + let profiles = profile_repo(&state); + match profiles + .find_by_profile_id_owned(&profile_id, &claims.sub) + .await + { + Ok(Some(_)) => {} + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "profile not found" })), + )); + } + Err(e) => { + tracing::error!("Profile lookup in delete_share failed: {}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )); + } + } + + let shares = share_repo(&state); + match shares.delete(&profile_id, &recipient_user_id).await { + Ok(true) => Ok(StatusCode::NO_CONTENT), + Ok(false) => Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "share not found" })), + )), + Err(e) => { + tracing::error!("Share delete failed: {}", e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )) + } + } +} + +// --------------------------------------------------------------------------- +// GET /api/profiles/shared-with-me — recipient lists profiles shared with them. +// --------------------------------------------------------------------------- + +pub async fn list_shared_with_me( + State(state): State, + Extension(claims): Extension, +) -> Result>, (StatusCode, Json)> { + let shares = share_repo(&state); + let my_shares = match shares.find_for_recipient(&claims.sub).await { + Ok(rows) => rows, + Err(e) => { + tracing::error!("shared-with-me list failed: {}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )); + } + }; + + // Join each share with its profile's metadata. Only return shares for + // profiles that still exist and are still active/unexpired (find_active + // semantics, re-checked here to avoid listing a just-expired share). + let profiles = profile_repo(&state); + let mut out = Vec::with_capacity(my_shares.len()); + for s in my_shares { + // Skip expired/inactive — find_for_recipient returns all rows; filter + // to currently-usable ones for the recipient's view. + if !s.active { + continue; + } + if let Some(exp) = s.expires_at { + if exp <= DateTime::now() { + continue; + } + } + let profile = match profiles.find_by_profile_id(&s.profile_id).await { + Ok(Some(p)) => p, + _ => continue, // profile deleted; skip silently + }; + out.push(SharedProfileResponse { + profile_id: profile.profile_id, + owner_account_id: profile.owner_account_id, + name_data: profile.name, + name_iv: profile.name_iv, + kind: profile.kind, + relationship: profile.relationship, + ephemeral_public_key: s.ephemeral_public_key, + wrapped_profile_dek: s.wrapped_profile_dek, + wrapped_profile_dek_iv: s.wrapped_profile_dek_iv, + permissions: s.permissions, + created_at: s.created_at, + }); + } + Ok(Json(out)) +} + +// --------------------------------------------------------------------------- +// GET /api/profiles/:id — extend the existing profile GET to admit recipients. +// This stands in for "the recipient can fetch one shared profile's metadata". +// Implemented here so the share-aware logic lives with the share code. +// --------------------------------------------------------------------------- + +/// Fetch a single profile's metadata, admitting both owner and share-recipient. +/// Returns the owner's-view `ProfileResponse` shape (the wrapped profile DEK +/// there is account-wrapped — recipients ignore it and use the share's +/// ECDH-wrapped DEK from /profiles/shared-with-me instead). +pub async fn get_profile_shared_aware( + State(state): State, + Extension(claims): Extension, + Path(profile_id): Path, +) -> Result, (StatusCode, Json)> { + // authorize_profile_read admits owner or active-share recipient. + let _owner = authorize_profile_read(&state, &claims, &profile_id).await?; + let profiles = profile_repo(&state); + match profiles.find_by_profile_id(&profile_id).await { + Ok(Some(p)) => Ok(Json(ProfileResponse::from(p))), + Ok(None) => Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "profile not found" })), + )), + Err(e) => { + tracing::error!("Profile fetch failed: {}", e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )) + } + } +} + +// Re-export ProfileResponse from the profile handlers for the shared-aware GET. +pub use crate::handlers::profile::ProfileResponse; + +// --------------------------------------------------------------------------- +// GET /api/users/public-key — look up a recipient's identity public key. +// Public (no auth) — public keys are not secret. +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct PublicKeyQuery { + pub email: String, +} + +#[derive(Debug, Serialize)] +pub struct PublicKeyResponse { + pub user_id: String, + pub identity_public_key: String, +} + +pub async fn get_public_key( + State(state): State, + Query(q): Query, +) -> Result, (StatusCode, Json)> { + let user = match state.db.find_user_by_email(&q.email).await { + Ok(Some(u)) => u, + Ok(None) | Err(_) => { + // Don't leak whether the email exists. + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "no public key for this address" })), + )); + } + }; + let user_id = user + .id + .ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "invalid user state" })), + ))? + .to_string(); + let pk = user.identity_public_key.unwrap_or_default(); + if pk.is_empty() { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "no public key for this address" })), + )); + } + Ok(Json(PublicKeyResponse { + user_id, + 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/handlers/shares.rs b/backend/src/handlers/shares.rs deleted file mode 100644 index 4481f96..0000000 --- a/backend/src/handlers/shares.rs +++ /dev/null @@ -1,453 +0,0 @@ -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, - Extension, Json, -}; -use mongodb::bson::oid::ObjectId; -use serde::{Deserialize, Serialize}; -use validator::Validate; - -use crate::{ - auth::jwt::Claims, - config::AppState, - models::{permission::Permission, share::Share}, -}; - -#[derive(Debug, Deserialize, Validate)] -pub struct CreateShareRequest { - pub target_user_email: String, - pub resource_type: String, - pub resource_id: Option, - pub permissions: Vec, - #[serde(default)] - pub expires_days: Option, -} - -#[derive(Debug, Serialize)] -pub struct ShareResponse { - pub id: String, - pub target_user_id: String, - pub resource_type: String, - pub resource_id: Option, - pub permissions: Vec, - pub expires_at: Option, - pub created_at: i64, - pub active: bool, -} - -impl TryFrom for ShareResponse { - type Error = anyhow::Error; - - fn try_from(share: Share) -> Result { - Ok(Self { - id: share.id.map(|id| id.to_string()).unwrap_or_default(), - target_user_id: share.target_user_id.to_string(), - resource_type: share.resource_type, - resource_id: share.resource_id.map(|id| id.to_string()), - permissions: share - .permissions - .into_iter() - .map(|p| p.to_string()) - .collect(), - expires_at: share.expires_at.map(|dt| dt.timestamp_millis()), - created_at: share.created_at.timestamp_millis(), - active: share.active, - }) - } -} - -pub async fn create_share( - State(state): State, - Extension(claims): Extension, - Json(req): Json, -) -> impl IntoResponse { - if let Err(errors) = req.validate() { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "validation failed", - "details": errors.to_string() - })), - ) - .into_response(); - } - - // Find target user by email - let target_user = match state.db.find_user_by_email(&req.target_user_email).await { - Ok(Some(user)) => user, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": "target user not found" - })), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to find target user: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "database error" - })), - ) - .into_response(); - } - }; - - let target_user_id = match target_user.id { - Some(id) => id, - None => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "target user has no ID" - })), - ) - .into_response(); - } - }; - - let owner_id = match ObjectId::parse_str(&claims.sub) { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "invalid user ID format" - })), - ) - .into_response(); - } - }; - - // Parse resource_id if provided - let resource_id = match req.resource_id { - Some(id) => match ObjectId::parse_str(&id) { - Ok(oid) => Some(oid), - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "invalid resource_id format" - })), - ) - .into_response(); - } - }, - None => None, - }; - - // Parse permissions - support all permission types - let permissions: Vec = req - .permissions - .into_iter() - .filter_map(|p| match p.to_lowercase().as_str() { - "read" => Some(Permission::Read), - "write" => Some(Permission::Write), - "delete" => Some(Permission::Delete), - "share" => Some(Permission::Share), - "admin" => Some(Permission::Admin), - _ => None, - }) - .collect(); - - if permissions.is_empty() { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "at least one valid permission is required (read, write, delete, share, admin)" - }))).into_response(); - } - - // Calculate expiration - let expires_at = req.expires_days.map(|days| { - mongodb::bson::DateTime::now().saturating_add_millis(days as i64 * 24 * 60 * 60 * 1000) - }); - - let share = Share::new( - owner_id, - target_user_id, - req.resource_type, - resource_id, - permissions, - expires_at, - ); - - match state.db.create_share(&share).await { - Ok(_) => { - let response: ShareResponse = match share.try_into() { - Ok(r) => r, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "failed to create share response" - })), - ) - .into_response(); - } - }; - (StatusCode::CREATED, Json(response)).into_response() - } - Err(e) => { - tracing::error!("Failed to create share: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "failed to create share" - })), - ) - .into_response() - } - } -} - -pub async fn list_shares( - State(state): State, - Extension(claims): Extension, -) -> impl IntoResponse { - let user_id = &claims.sub; - - match state.db.list_shares_for_user(user_id).await { - Ok(shares) => { - let responses: Vec = shares - .into_iter() - .filter_map(|s| ShareResponse::try_from(s).ok()) - .collect(); - (StatusCode::OK, Json(responses)).into_response() - } - Err(e) => { - tracing::error!("Failed to list shares: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "failed to list shares" - })), - ) - .into_response() - } - } -} - -pub async fn get_share(State(state): State, Path(id): Path) -> impl IntoResponse { - match state.db.get_share(&id).await { - Ok(Some(share)) => { - let response: ShareResponse = match share.try_into() { - Ok(r) => r, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "failed to create share response" - })), - ) - .into_response(); - } - }; - (StatusCode::OK, Json(response)).into_response() - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": "share not found" - })), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get share: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "failed to get share" - })), - ) - .into_response() - } - } -} - -#[derive(Debug, Deserialize, Validate)] -pub struct UpdateShareRequest { - pub permissions: Option>, - #[serde(default)] - pub active: Option, - #[serde(default)] - pub expires_days: Option, -} - -pub async fn update_share( - State(state): State, - Path(id): Path, - Extension(claims): Extension, - Json(req): Json, -) -> impl IntoResponse { - // First get the share - let mut share = match state.db.get_share(&id).await { - Ok(Some(s)) => s, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": "share not found" - })), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get share: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "failed to get share" - })), - ) - .into_response(); - } - }; - - // Verify ownership - let owner_id = match ObjectId::parse_str(&claims.sub) { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "invalid user ID format" - })), - ) - .into_response(); - } - }; - - if share.owner_id != owner_id { - return ( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ - "error": "not authorized to modify this share" - })), - ) - .into_response(); - } - - // Update fields - if let Some(permissions) = req.permissions { - share.permissions = permissions - .into_iter() - .filter_map(|p| match p.to_lowercase().as_str() { - "read" => Some(Permission::Read), - "write" => Some(Permission::Write), - "delete" => Some(Permission::Delete), - "share" => Some(Permission::Share), - "admin" => Some(Permission::Admin), - _ => None, - }) - .collect(); - } - - if let Some(active) = req.active { - share.active = active; - } - - if let Some(days) = req.expires_days { - share.expires_at = Some( - mongodb::bson::DateTime::now().saturating_add_millis(days as i64 * 24 * 60 * 60 * 1000), - ); - } - - match state.db.update_share(&share).await { - Ok(_) => { - let response: ShareResponse = match share.try_into() { - Ok(r) => r, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "failed to create share response" - })), - ) - .into_response(); - } - }; - (StatusCode::OK, Json(response)).into_response() - } - Err(e) => { - tracing::error!("Failed to update share: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "failed to update share" - })), - ) - .into_response() - } - } -} - -pub async fn delete_share( - State(state): State, - Path(id): Path, - Extension(claims): Extension, -) -> impl IntoResponse { - // First get the share to verify ownership - let share = match state.db.get_share(&id).await { - Ok(Some(s)) => s, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": "share not found" - })), - ) - .into_response() - } - Err(e) => { - tracing::error!("Failed to get share: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "failed to get share" - })), - ) - .into_response(); - } - }; - - // Verify ownership - let owner_id = match ObjectId::parse_str(&claims.sub) { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "invalid user ID format" - })), - ) - .into_response(); - } - }; - - if share.owner_id != owner_id { - return ( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ - "error": "not authorized to delete this share" - })), - ) - .into_response(); - } - - match state.db.delete_share(&id).await { - Ok(_) => (StatusCode::NO_CONTENT, ()).into_response(), - Err(e) => { - tracing::error!("Failed to delete share: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "failed to delete share" - })), - ) - .into_response() - } - } -} diff --git a/backend/src/handlers/users.rs b/backend/src/handlers/users.rs index 0dc0660..9b3ea33 100644 --- a/backend/src/handlers/users.rs +++ b/backend/src/handlers/users.rs @@ -39,7 +39,7 @@ pub struct UpdateProfileRequest { pub username: Option, } -pub async fn get_profile( +pub async fn get_account( State(state): State, Extension(claims): Extension, ) -> impl IntoResponse { @@ -79,7 +79,7 @@ pub async fn get_profile( } } -pub async fn update_profile( +pub async fn update_account( State(state): State, Extension(claims): Extension, Json(req): Json, diff --git a/backend/src/middleware/permission.rs b/backend/src/middleware/permission.rs deleted file mode 100644 index d265054..0000000 --- a/backend/src/middleware/permission.rs +++ /dev/null @@ -1,96 +0,0 @@ -use axum::{ - extract::{Request, State}, - http::StatusCode, - middleware::Next, - response::Response, -}; -use crate::config::AppState; -use crate::auth::Claims; - -/// Middleware to check if user has permission for a resource -/// -/// This middleware checks JWT claims (attached by auth middleware) -/// and verifies the user has the required permission level. -/// -/// # Permission Levels -/// - "read": Can view resource -/// - "write": Can modify resource -/// - "admin": Full control including deletion -pub async fn has_permission( - State(state): State, - required_permission: String, - request: Request, - next: Next, -) -> Result { - // Extract user_id from JWT claims (attached by auth middleware) - let user_id = match request.extensions().get::() { - Some(claims) => claims.sub.clone(), - None => return Err(StatusCode::UNAUTHORIZED), - }; - - // Extract resource_id from URL path - let resource_id = match extract_resource_id(request.uri().path()) { - Some(id) => id, - None => return Err(StatusCode::BAD_REQUEST), - }; - - // Check if user has the required permission (either directly or through shares) - let has_perm = match state.db - .check_permission(&user_id, &resource_id, &required_permission) - .await - { - Ok(allowed) => allowed, - Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), - }; - - if !has_perm { - return Err(StatusCode::FORBIDDEN); - } - - Ok(next.run(request).await) -} - -/// Extract resource ID from URL path -/// -/// # Examples -/// - /api/shares/123 -> Some("123") -/// - /api/users/me/profile -> None -fn extract_resource_id(path: &str) -> Option { - let segments: Vec<&str> = path.split('/').collect(); - - // Look for ID segment after a resource type - // e.g., /api/shares/:id - for (i, segment) in segments.iter().enumerate() { - if segment == &"shares" || segment == &"permissions" { - if i + 1 < segments.len() { - let id = segments[i + 1]; - if !id.is_empty() { - return Some(id.to_string()); - } - } - } - } - - None -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_resource_id() { - assert_eq!( - extract_resource_id("/api/shares/123"), - Some("123".to_string()) - ); - assert_eq!( - extract_resource_id("/api/shares/abc-123"), - Some("abc-123".to_string()) - ); - assert_eq!( - extract_resource_id("/api/users/me"), - None - ); - } -} diff --git a/backend/src/models/appointment.rs b/backend/src/models/appointment.rs index d6d5efb..8ba2140 100644 --- a/backend/src/models/appointment.rs +++ b/backend/src/models/appointment.rs @@ -126,16 +126,21 @@ impl AppointmentRepository { Ok(appointment) } - /// List a user's appointments, optionally filtered by top-level `status`. + /// List a user's appointments, optionally filtered by top-level `status` + /// and/or `profileId`. pub async fn find_by_user_filtered( &self, user_id: &str, status: Option<&str>, + profile_id: Option<&str>, ) -> Result, Box> { let mut filter = doc! { "userId": user_id }; if let Some(s) = status { filter.insert("status", s); } + if let Some(p) = profile_id { + filter.insert("profileId", p); + } let mut cursor = self.collection.find(filter, None).await?; let mut appointments = Vec::new(); while let Some(appt) = cursor.next().await { diff --git a/backend/src/models/health_stats.rs b/backend/src/models/health_stats.rs index 5f28485..4b11669 100644 --- a/backend/src/models/health_stats.rs +++ b/backend/src/models/health_stats.rs @@ -15,6 +15,9 @@ pub struct HealthStatistic { #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] pub id: Option, pub user_id: String, + /// Which profile this stat belongs to (plaintext, filterable). + #[serde(rename = "profileId", default)] + pub profile_id: String, /// Opaque client-encrypted blob (value, unit, type, notes inside). #[serde(rename = "encryptedData")] pub encrypted_data: EncryptedFieldWire, @@ -27,6 +30,7 @@ pub struct HealthStatistic { pub struct HealthStatResponse { pub id: String, pub user_id: String, + pub profile_id: String, pub encrypted_data: EncryptedFieldWire, pub recorded_at: String, } @@ -36,6 +40,7 @@ impl From for HealthStatResponse { HealthStatResponse { id: s.id.map(|o| o.to_hex()).unwrap_or_default(), user_id: s.user_id, + profile_id: s.profile_id, encrypted_data: s.encrypted_data, recorded_at: s.recorded_at, } @@ -70,6 +75,21 @@ impl HealthStatisticsRepository { cursor.try_collect().await } + /// All stats for a user, optionally filtered by profile_id at the Mongo + /// level (real top-level document field). + pub async fn find_by_user_filtered( + &self, + user_id: &str, + profile_id: Option<&str>, + ) -> Result, MongoError> { + let mut filter = doc! { "user_id": user_id }; + if let Some(pid) = profile_id { + filter.insert("profileId", pid); + } + let cursor = self.collection.find(filter, None).await?; + cursor.try_collect().await + } + pub async fn find_by_id(&self, id: &ObjectId) -> Result, MongoError> { let filter = doc! { "_id": id }; self.collection.find_one(filter, None).await diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index bc326e8..175d86d 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -6,9 +6,8 @@ pub mod health_stats; pub mod interactions; pub mod lab_result; pub mod medication; -pub mod permission; pub mod profile; +pub mod profile_share; pub mod refresh_token; pub mod session; -pub mod share; pub mod user; diff --git a/backend/src/models/permission.rs b/backend/src/models/permission.rs deleted file mode 100644 index db0513c..0000000 --- a/backend/src/models/permission.rs +++ /dev/null @@ -1,44 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::fmt; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum Permission { - Read, - Write, - Delete, - Share, - Admin, -} - -impl fmt::Display for Permission { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Read => write!(f, "read"), - Self::Write => write!(f, "write"), - Self::Delete => write!(f, "delete"), - Self::Share => write!(f, "share"), - Self::Admin => write!(f, "admin"), - } - } -} - -impl Permission { - pub fn can_read(&self) -> bool { - matches!(self, Self::Read | Self::Admin) - } - - pub fn can_write(&self) -> bool { - matches!(self, Self::Write | Self::Admin) - } - - pub fn can_delete(&self) -> bool { - matches!(self, Self::Delete | Self::Admin) - } - - pub fn can_share(&self) -> bool { - matches!(self, Self::Share | Self::Admin) - } -} - -pub type Permissions = Vec; diff --git a/backend/src/models/profile.rs b/backend/src/models/profile.rs index 3881064..4852243 100644 --- a/backend/src/models/profile.rs +++ b/backend/src/models/profile.rs @@ -1,35 +1,76 @@ +use futures::stream::StreamExt; use mongodb::{ bson::{doc, oid::ObjectId, DateTime}, Collection, }; use serde::{Deserialize, Serialize}; +/// A profile (a "subject of care" — a person or pet whose health data is +/// tracked). One account owns zero or more profiles. All health data +/// (medications, appointments, health stats) is scoped to a profile and +/// encrypted under that profile's DEK. +/// +/// Zero-knowledge layers (both opaque to the server): +/// - `name` / `wrapped_profile_dek` are AES-256-GCM ciphertext blobs the +/// server stores verbatim and cannot read. +/// - `name` is the profile's display name encrypted under the profile DEK. +/// - `wrapped_profile_dek` is the profile DEK encrypted under the owner's +/// account DEK. The client unwraps it at login/unlock (after unwrapping +/// the account DEK) to get the profile DEK. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Profile { #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] pub id: Option, #[serde(rename = "profileId")] pub profile_id: String, + /// Owner account id (Mongo ObjectId hex). Replaces the 1:1 `user_id` + /// semantics from the pre-A2 single-profile model. + #[serde(rename = "ownerAccountId")] + pub owner_account_id: String, + /// Kept populated = owner_account_id for backward compat with existing + /// medication/appointment filters that key on `userId`. Same value; do + /// not rely on it for ownership — use `owner_account_id`. #[serde(rename = "userId")] pub user_id: String, #[serde(rename = "familyId")] pub family_id: Option, + /// Display name — opaque, encrypted under the profile DEK. #[serde(rename = "name")] pub name: String, #[serde(rename = "nameIv")] pub name_iv: String, #[serde(rename = "nameAuthTag")] pub name_auth_tag: String, - #[serde(rename = "role")] + /// "human" | "pet" — what kind of subject this profile tracks. + #[serde(rename = "kind", default = "default_kind")] + pub kind: String, + /// Freeform relationship to the owner: "self", "child", "spouse", + /// "parent", "pet", ... + #[serde(rename = "relationship", default)] + pub relationship: String, + #[serde(rename = "role", default = "default_role")] pub role: String, - #[serde(rename = "permissions")] + #[serde(rename = "permissions", default)] pub permissions: Vec, + /// Profile DEK wrapped under the owner's account DEK (opaque ciphertext). + #[serde(rename = "wrappedProfileDek")] + pub wrapped_profile_dek: String, + #[serde(rename = "wrappedProfileDekIv")] + pub wrapped_profile_dek_iv: String, #[serde(rename = "createdAt")] pub created_at: DateTime, #[serde(rename = "updatedAt")] pub updated_at: DateTime, } +fn default_kind() -> String { + "human".to_string() +} + +fn default_role() -> String { + "patient".to_string() +} + pub struct ProfileRepository { collection: Collection, } @@ -44,6 +85,7 @@ impl ProfileRepository { Ok(()) } + /// Look up a profile by its application-level profile id. pub async fn find_by_profile_id( &self, profile_id: &str, @@ -53,32 +95,102 @@ impl ProfileRepository { .await } - /// Look up a profile by its owning user id. - pub async fn find_by_user_id(&self, user_id: &str) -> mongodb::error::Result> { + /// Look up a profile by id AND owner — used for ownership-scoped access. + /// Returns None if the profile doesn't exist or doesn't belong to `owner`. + pub async fn find_by_profile_id_owned( + &self, + profile_id: &str, + owner: &str, + ) -> mongodb::error::Result> { self.collection - .find_one(doc! { "userId": user_id }, None) + .find_one( + doc! { "profileId": profile_id, "ownerAccountId": owner }, + None, + ) .await } - /// Update the profile's display name (opaque client-encrypted blob). - pub async fn update_encrypted_name( + /// All profiles owned by an account. + pub async fn find_all_by_owner(&self, owner: &str) -> mongodb::error::Result> { + let mut cursor = self + .collection + .find(doc! { "ownerAccountId": owner }, None) + .await?; + let mut out = Vec::new(); + while let Some(p) = cursor.next().await { + out.push(p?); + } + Ok(out) + } + + /// Replace a profile's mutable fields (display name blob, kind, + /// relationship), keyed by (profile_id, owner). Returns the updated doc + /// or None if the profile doesn't exist / isn't owned by `owner`. + pub async fn update_profile( &self, - user_id: &str, + profile_id: &str, + owner: &str, name_data: &str, name_iv: &str, name_auth_tag: &str, + kind: &str, + relationship: &str, ) -> mongodb::error::Result> { self.collection .find_one_and_update( - doc! { "userId": user_id }, + doc! { "profileId": profile_id, "ownerAccountId": owner }, doc! { "$set": { "name": name_data, "nameIv": name_iv, "nameAuthTag": name_auth_tag, + "kind": kind, + "relationship": relationship, "updatedAt": DateTime::now() }}, None, ) .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( + &self, + profile_id: &str, + owner: &str, + ) -> mongodb::error::Result { + let res = self + .collection + .delete_one( + doc! { "profileId": profile_id, "ownerAccountId": owner }, + None, + ) + .await?; + Ok(res.deleted_count > 0) + } } diff --git a/backend/src/models/profile_share.rs b/backend/src/models/profile_share.rs new file mode 100644 index 0000000..1fc50fd --- /dev/null +++ b/backend/src/models/profile_share.rs @@ -0,0 +1,222 @@ +use futures::stream::StreamExt; +use mongodb::{ + bson::{doc, oid::ObjectId, DateTime}, + Collection, +}; +use serde::{Deserialize, Serialize}; + +/// A profile shared from one account (owner) to another (recipient). +/// +/// Zero-knowledge envelope (Phase B, see `docs/adr/multi-person-sharing.md`): +/// the owner wraps the profile DEK to the recipient's X25519 identity public +/// key via ECDH, using a fresh ephemeral keypair per share. The server stores +/// only opaque ciphertext + public keys and cannot read the profile DEK. +/// +/// `permissions` reserves `read` (Phase B), `write`, and `admin` for later +/// phases. Phase B only grants read access. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfileShare { + #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(rename = "profileId")] + pub profile_id: String, + /// Owner's account id (the profile's owner). Denormalized from the + /// profile for query efficiency without a join. + #[serde(rename = "ownerUserId")] + pub owner_user_id: String, + /// Recipient's account id. + #[serde(rename = "recipientUserId")] + pub recipient_user_id: String, + /// Ephemeral X25519 public key (base64 raw) generated for this share. The + /// recipient combines it with their identity private key to ECDH-derive + /// the wrapping key. Plaintext — public keys are not secret. + #[serde(rename = "ephemeralPublicKey")] + pub ephemeral_public_key: String, + /// Profile DEK wrapped (AES-256-GCM) under the ECDH-derived key. Opaque. + #[serde(rename = "wrappedProfileDek")] + pub wrapped_profile_dek: String, + #[serde(rename = "wrappedProfileDekIv")] + pub wrapped_profile_dek_iv: String, + /// Reserved for later phases. Phase B writes `["read"]`. + #[serde(rename = "permissions", default = "default_read")] + pub permissions: Vec, + /// Optional expiry; if set and past, `find_active` treats the share as gone. + #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + #[serde(rename = "createdAt")] + pub created_at: DateTime, + /// Supports a future "disable without delete" path. Phase B soft-revoke + /// hard-deletes the doc, but the field is kept for forward-compat. + #[serde(rename = "active", default = "default_true")] + pub active: bool, +} + +fn default_read() -> Vec { + vec!["read".to_string()] +} + +fn default_true() -> bool { + true +} + +pub struct ProfileShareRepository { + collection: Collection, +} + +impl ProfileShareRepository { + pub fn new(collection: Collection) -> Self { + Self { collection } + } + + pub async fn create(&self, share: &ProfileShare) -> mongodb::error::Result<()> { + self.collection.insert_one(share, None).await?; + Ok(()) + } + + /// All shares where the given account is the recipient (for the + /// `/profiles/shared-with-me` endpoint). + pub async fn find_for_recipient( + &self, + recipient_user_id: &str, + ) -> mongodb::error::Result> { + let mut cursor = self + .collection + .find(doc! { "recipientUserId": recipient_user_id }, None) + .await?; + let mut out = Vec::new(); + while let Some(s) = cursor.next().await { + out.push(s?); + } + Ok(out) + } + + /// All shares for a given profile (owner listing who they've shared with). + pub async fn find_for_profile( + &self, + profile_id: &str, + ) -> mongodb::error::Result> { + let mut cursor = self + .collection + .find(doc! { "profileId": profile_id }, None) + .await?; + let mut out = Vec::new(); + while let Some(s) = cursor.next().await { + out.push(s?); + } + Ok(out) + } + + /// A specific (profile, recipient) share regardless of active/expiry state. + pub async fn find( + &self, + profile_id: &str, + recipient_user_id: &str, + ) -> mongodb::error::Result> { + self.collection + .find_one( + doc! { "profileId": profile_id, "recipientUserId": recipient_user_id }, + None, + ) + .await + } + + /// A specific (profile, recipient) share, only if currently usable: + /// `active == true` and not past `expires_at`. Used by the share-gate. + pub async fn find_active( + &self, + profile_id: &str, + recipient_user_id: &str, + ) -> mongodb::error::Result> { + let now = DateTime::now(); + // active==true AND (expiresAt missing OR expiresAt > now) + let filter = doc! { + "profileId": profile_id, + "recipientUserId": recipient_user_id, + "active": true, + "$or": [ + { "expiresAt": { "$exists": false } }, + { "expiresAt": null }, + { "expiresAt": { "$gt": now } }, + ], + }; + 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( + &self, + profile_id: &str, + recipient_user_id: &str, + ) -> mongodb::error::Result { + let res = self + .collection + .delete_one( + doc! { "profileId": profile_id, "recipientUserId": recipient_user_id }, + None, + ) + .await?; + Ok(res.deleted_count > 0) + } + + /// Upsert: replace any existing (profile, recipient) share with the given + /// one. Used when re-sharing (e.g. rotating the ephemeral key). Deletes + /// existing rows for the pair first, then inserts. + pub async fn upsert(&self, share: &ProfileShare) -> mongodb::error::Result<()> { + let _ = self + .collection + .delete_one( + doc! { + "profileId": &share.profile_id, + "recipientUserId": &share.recipient_user_id, + }, + None, + ) + .await?; + self.collection.insert_one(share, None).await?; + Ok(()) + } +} diff --git a/backend/src/models/share.rs b/backend/src/models/share.rs deleted file mode 100644 index 2a9df80..0000000 --- a/backend/src/models/share.rs +++ /dev/null @@ -1,123 +0,0 @@ -use mongodb::bson::DateTime; -use mongodb::bson::{doc, oid::ObjectId}; -use mongodb::Collection; -use serde::{Deserialize, Serialize}; - -use super::permission::Permission; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Share { - #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] - pub id: Option, - pub owner_id: ObjectId, - pub target_user_id: ObjectId, - pub resource_type: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub resource_id: Option, - pub permissions: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub expires_at: Option, - pub created_at: DateTime, - pub active: bool, -} - -impl Share { - pub fn new( - owner_id: ObjectId, - target_user_id: ObjectId, - resource_type: String, - resource_id: Option, - permissions: Vec, - expires_at: Option, - ) -> Self { - Self { - id: None, - owner_id, - target_user_id, - resource_type, - resource_id, - permissions, - expires_at, - created_at: DateTime::now(), - active: true, - } - } - - pub fn is_expired(&self) -> bool { - if let Some(expires) = self.expires_at { - DateTime::now() > expires - } else { - false - } - } - - pub fn has_permission(&self, permission: &Permission) -> bool { - self.permissions.contains(permission) || self.permissions.contains(&Permission::Admin) - } - - pub fn revoke(&mut self) { - self.active = false; - } -} - -#[derive(Clone)] -pub struct ShareRepository { - collection: Collection, -} - -impl ShareRepository { - pub fn new(collection: Collection) -> Self { - Self { collection } - } - - pub async fn create(&self, share: &Share) -> mongodb::error::Result> { - let result = self.collection.insert_one(share, None).await?; - Ok(Some(result.inserted_id.as_object_id().unwrap())) - } - - pub async fn find_by_id(&self, id: &ObjectId) -> mongodb::error::Result> { - self.collection.find_one(doc! { "_id": id }, None).await - } - - pub async fn find_by_owner(&self, owner_id: &ObjectId) -> mongodb::error::Result> { - use futures::stream::TryStreamExt; - - self.collection - .find(doc! { "owner_id": owner_id }, None) - .await? - .try_collect() - .await - } - - pub async fn find_by_target( - &self, - target_user_id: &ObjectId, - ) -> mongodb::error::Result> { - use futures::stream::TryStreamExt; - - self.collection - .find( - doc! { "target_user_id": target_user_id, "active": true }, - None, - ) - .await? - .try_collect() - .await - } - - pub async fn update(&self, share: &Share) -> mongodb::error::Result<()> { - if let Some(id) = &share.id { - self.collection - .replace_one(doc! { "_id": id }, share, None) - .await?; - } - Ok(()) - } - - pub async fn delete(&self, share_id: &ObjectId) -> mongodb::error::Result<()> { - self.collection - .delete_one(doc! { "_id": share_id }, None) - .await?; - Ok(()) - } -} diff --git a/backend/src/models/user.rs b/backend/src/models/user.rs index 74de914..bbb0eb0 100644 --- a/backend/src/models/user.rs +++ b/backend/src/models/user.rs @@ -37,6 +37,21 @@ pub struct User { #[serde(skip_serializing_if = "Option::is_none")] pub recovery_wrapped_dek_iv: Option, + /// Account X25519 identity public key (base64 raw). Plaintext — public keys + /// are not secret. Other accounts wrap profile DEKs to this key (Phase B, + /// see docs/adr/multi-person-sharing.md). Generated client-side at + /// registration. + #[serde(skip_serializing_if = "Option::is_none")] + pub identity_public_key: Option, + + /// Account X25519 identity private key, wrapped (AES-256-GCM encrypted) + /// under the account DEK. The server stores this verbatim and cannot + /// decrypt it. The client unwraps it at login after unwrapping the DEK. + #[serde(skip_serializing_if = "Option::is_none")] + pub identity_private_key_wrapped: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub identity_private_key_wrapped_iv: Option, + /// Token version for invalidating all tokens on password change pub token_version: i32, @@ -93,6 +108,9 @@ impl User { wrapped_dek_iv: None, recovery_wrapped_dek: None, recovery_wrapped_dek_iv: None, + identity_public_key: None, + identity_private_key_wrapped: None, + identity_private_key_wrapped_iv: None, token_version: 0, created_at: now, last_active: now, diff --git a/backend/tests/auth_tests.rs b/backend/tests/auth_tests.rs index 3da535a..ce7b24f 100644 --- a/backend/tests/auth_tests.rs +++ b/backend/tests/auth_tests.rs @@ -287,6 +287,80 @@ async fn password_change_invalidates_existing_tokens() { common::drop_test_db(&db_name).await; } +#[tokio::test] +async fn identity_keypair_round_trips_through_register_and_login() { + // Phase A1 (#3): the X25519 identity keypair fields (public plaintext, + // private wrapped under the account DEK) are stored verbatim at register + // and echoed back on login. The server never inspects or derives them. + let (app, db_name) = require_app!(common::app_for_test().await); + let email = unique_email(); + + // Opaque blobs — the server treats these as base64 strings, nothing more. + let public_key = "base64-x25519-public-key-for-test"; + let priv_wrapped = "base64-wrapped-priv-ciphertext"; + let priv_wrapped_iv = "base64-12-byte-iv"; + + let (status, body) = common::send_json( + &app, + "POST", + "/api/auth/register", + Some(json!({ + "email": email, + "username": "tester", + "password": "supersecret", + "identity_public_key": public_key, + "identity_private_key_wrapped": priv_wrapped, + "identity_private_key_wrapped_iv": priv_wrapped_iv, + })), + None, + ) + .await; + assert_eq!(status, 201, "register should return 201, body: {body}"); + assert_eq!(body["identity_public_key"], public_key); + assert_eq!(body["identity_private_key_wrapped"], priv_wrapped); + assert_eq!(body["identity_private_key_wrapped_iv"], priv_wrapped_iv); + + // Login must echo the same stored values. + let (status, body) = common::send_json( + &app, + "POST", + "/api/auth/login", + Some(json!({ "email": email, "password": "supersecret" })), + None, + ) + .await; + assert_eq!(status, 200, "login should return 200, body: {body}"); + assert_eq!(body["identity_public_key"], public_key); + assert_eq!(body["identity_private_key_wrapped"], priv_wrapped); + assert_eq!(body["identity_private_key_wrapped_iv"], priv_wrapped_iv); + + common::drop_test_db(&db_name).await; +} + +#[tokio::test] +async fn register_without_identity_keypair_stays_optional() { + // Backward compat: existing clients that don't send identity fields must + // still register successfully, and the fields are absent from the response. + let (app, db_name) = require_app!(common::app_for_test().await); + let email = unique_email(); + + let (status, body) = common::send_json( + &app, + "POST", + "/api/auth/register", + Some(json!({ "email": email, "username": "tester", "password": "supersecret" })), + None, + ) + .await; + assert_eq!(status, 201, "register should return 201, body: {body}"); + assert!( + body.get("identity_public_key").is_none() || body["identity_public_key"].is_null(), + "identity_public_key should be absent when not provided: {body}" + ); + + common::drop_test_db(&db_name).await; +} + // ---- helpers ---- fn unique_email() -> String { diff --git a/backend/tests/ownership_tests.rs b/backend/tests/ownership_tests.rs new file mode 100644 index 0000000..b5cc3b9 --- /dev/null +++ b/backend/tests/ownership_tests.rs @@ -0,0 +1,258 @@ +//! Write/delete path ownership tests (#12). +//! +//! Verifies the ownership checks added to fix the IDOR: user A creates a +//! medication/appointment; user B cannot update, delete, log-dose-against, or +//! read adherence for it. All cross-user attempts must 404 (we use 404, not +//! 403, to avoid leaking the existence of other users' records). +//! +//! 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 user and return the access token. +async fn register(app: &axum::Router, email: &str) -> String { + let (status, body) = common::send_json( + app, + "POST", + "/api/auth/register", + Some(json!({ "email": email, "username": "tester", "password": "supersecret" })), + None, + ) + .await; + assert_eq!(status, 201, "register failed, body: {body}"); + body["token"].as_str().unwrap().to_string() +} + +/// Create a medication as `token` and return its medication_id. +async fn create_medication(app: &axum::Router, token: &str) -> String { + let (status, body) = common::send_json( + app, + "POST", + "/api/medications", + Some(json!({ + "profile_id": "default", + "encrypted_data": { "data": "b3duZXItYmxvYg==", "iv": "aXZ2aXZ2aXZ2aXZ2" }, + })), + Some(token), + ) + .await; + assert!( + status == 200 || status == 201, + "create_medication failed: {status} {body}" + ); + body["medication_id"].as_str().unwrap().to_string() +} + +/// Create an appointment as `token` and return its appointment_id. +async fn create_appointment(app: &axum::Router, token: &str) -> String { + let (status, body) = common::send_json( + app, + "POST", + "/api/appointments", + Some(json!({ + "profile_id": "default", + "encrypted_data": { "data": "YXBwdC1ibG9i", "iv": "aXZ2aXZ2aXZ2aXZ2" }, + "status": "upcoming", + })), + Some(token), + ) + .await; + assert!( + status == 200 || status == 201, + "create_appointment failed: {status} {body}" + ); + body["appointment_id"].as_str().unwrap().to_string() +} + +#[tokio::test] +async fn other_user_cannot_update_or_delete_medication() { + let (app, db_name) = require_app!(common::app_for_test().await); + let token_a = register(&app, &unique_email()).await; + let token_b = register(&app, &unique_email()).await; + let med_id = create_medication(&app, &token_a).await; + + // B cannot UPDATE A's medication. + let (status, _body) = common::send_json( + &app, + "POST", + &format!("/api/medications/{med_id}"), + Some(json!({ + "encrypted_data": { "data": "aGFja2Vk", "iv": "aXZ2aXZ2aXZ2aXZ2" }, + })), + Some(&token_b), + ) + .await; + assert_eq!(status, 404, "B must not update A's medication"); + + // B cannot DELETE A's medication. + let (status, _) = common::send_json( + &app, + "POST", + &format!("/api/medications/{med_id}/delete"), + None, + Some(&token_b), + ) + .await; + assert_eq!(status, 404, "B must not delete A's medication"); + + // A still sees it intact. + let (status, _body) = common::send_json( + &app, + "GET", + &format!("/api/medications/{med_id}"), + None, + Some(&token_a), + ) + .await; + assert_eq!(status, 200, "A's medication must be untouched"); + + common::drop_test_db(&db_name).await; +} + +#[tokio::test] +async fn other_user_cannot_log_dose_or_read_adherence_for_others_medication() { + let (app, db_name) = require_app!(common::app_for_test().await); + let token_a = register(&app, &unique_email()).await; + let token_b = register(&app, &unique_email()).await; + let med_id = create_medication(&app, &token_a).await; + + // B cannot LOG A DOSE against A's medication (would skew A's adherence). + let (status, _body) = common::send_json( + &app, + "POST", + &format!("/api/medications/{med_id}/log"), + Some(json!({ "taken": true })), + Some(&token_b), + ) + .await; + assert_eq!(status, 404, "B must not log a dose against A's medication"); + + // B cannot READ A's adherence (leaks dose history). + let (status, _body) = common::send_json( + &app, + "GET", + &format!("/api/medications/{med_id}/adherence"), + None, + Some(&token_b), + ) + .await; + assert_eq!(status, 404, "B must not read A's adherence"); + + // A can both (sanity). + let (status, _body) = common::send_json( + &app, + "POST", + &format!("/api/medications/{med_id}/log"), + Some(json!({ "taken": true })), + Some(&token_a), + ) + .await; + assert_eq!(status, 201, "A should be able to log a dose: {_body}"); + let (status, _body) = common::send_json( + &app, + "GET", + &format!("/api/medications/{med_id}/adherence"), + None, + Some(&token_a), + ) + .await; + assert_eq!(status, 200, "A should be able to read adherence"); + + common::drop_test_db(&db_name).await; +} + +#[tokio::test] +async fn other_user_cannot_update_or_delete_appointment() { + let (app, db_name) = require_app!(common::app_for_test().await); + let token_a = register(&app, &unique_email()).await; + let token_b = register(&app, &unique_email()).await; + let appt_id = create_appointment(&app, &token_a).await; + + // B cannot UPDATE A's appointment. + let (status, _body) = common::send_json( + &app, + "POST", + &format!("/api/appointments/{appt_id}"), + Some(json!({ + "encrypted_data": { "data": "aGFja2Vk", "iv": "aXZ2aXZ2aXZ2aXZ2" }, + "status": "cancelled", + })), + Some(&token_b), + ) + .await; + assert_eq!(status, 404, "B must not update A's appointment"); + + // B cannot DELETE A's appointment. + let (status, _) = common::send_json( + &app, + "POST", + &format!("/api/appointments/{appt_id}/delete"), + None, + Some(&token_b), + ) + .await; + assert_eq!(status, 404, "B must not delete A's appointment"); + + // A still sees it. + let (status, _body): (u16, Value) = common::send_json( + &app, + "GET", + &format!("/api/appointments/{appt_id}"), + None, + Some(&token_a), + ) + .await; + assert_eq!(status, 200, "A's appointment must be untouched"); + + common::drop_test_db(&db_name).await; +} + +#[tokio::test] +async fn owner_can_update_and_delete_own_medication() { + // Sanity: the new checks must not block the legitimate owner. + let (app, db_name) = require_app!(common::app_for_test().await); + let token = register(&app, &unique_email()).await; + let med_id = create_medication(&app, &token).await; + + let (status, _body) = common::send_json( + &app, + "POST", + &format!("/api/medications/{med_id}"), + Some(json!({ + "encrypted_data": { "data": "dXBkYXRlZA==", "iv": "aXZ2aXZ2aXZ2aXZ2" }, + })), + Some(&token), + ) + .await; + assert_eq!(status, 200, "owner should update: {_body}"); + + let (status, _) = common::send_json( + &app, + "POST", + &format!("/api/medications/{med_id}/delete"), + None, + Some(&token), + ) + .await; + assert_eq!(status, 204, "owner should delete"); + + common::drop_test_db(&db_name).await; +} diff --git a/backend/tests/profile_tests.rs b/backend/tests/profile_tests.rs new file mode 100644 index 0000000..2a9a470 --- /dev/null +++ b/backend/tests/profile_tests.rs @@ -0,0 +1,255 @@ +//! Multi-profile integration tests (Phase A2). +//! +//! Exercises the /api/profiles CRUD endpoints: list, create, get, update, +//! delete — all ownership-scoped. A second registered user must not be able +//! to read or modify the first user's profiles. +//! +//! Requires a live MongoDB; skips gracefully otherwise (see common/mod.rs). + +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; + } + } + }; +} + +/// Register a user (sending no default-profile fields → no profile auto-created) +/// and return the access token. +async fn register(app: &axum::Router, email: &str, password: &str) -> String { + let (status, body) = common::send_json( + app, + "POST", + "/api/auth/register", + Some(json!({ "email": email, "username": "tester", "password": password })), + None, + ) + .await; + assert_eq!(status, 201, "register precondition failed, body: {body}"); + body["token"].as_str().unwrap().to_string() +} + +fn unique_email() -> String { + format!("test_{}@example.com", uuid::Uuid::new_v4()) +} + +/// Body for creating a profile with a fake wrapped DEK (the server stores it +/// verbatim; it never inspects the contents). +fn create_body(kind: &str, relationship: &str) -> Value { + json!({ + "kind": kind, + "relationship": relationship, + "name_data": "base64-encrypted-name", + "name_iv": "base64-iv", + "wrapped_profile_dek": "base64-wrapped-profile-dek", + "wrapped_profile_dek_iv": "base64-dek-iv", + }) +} + +#[tokio::test] +async fn list_starts_empty_without_default_profile() { + let (app, db_name) = require_app!(common::app_for_test().await); + let token = register(&app, &unique_email(), "supersecret").await; + + let (status, body) = common::send_json(&app, "GET", "/api/profiles", None, Some(&token)).await; + assert_eq!(status, 200, "list should return 200, body: {body}"); + assert_eq!( + body.as_array().unwrap().len(), + 0, + "no default profile expected" + ); + + common::drop_test_db(&db_name).await; +} + +#[tokio::test] +async fn create_then_list_then_get_update_delete() { + let (app, db_name) = require_app!(common::app_for_test().await); + let token = register(&app, &unique_email(), "supersecret").await; + + // Create a profile. + let (status, body) = common::send_json( + &app, + "POST", + "/api/profiles", + Some(create_body("human", "self")), + Some(&token), + ) + .await; + assert_eq!(status, 201, "create should return 201, body: {body}"); + let profile_id = body["profile_id"].as_str().unwrap().to_string(); + assert_eq!(body["kind"], "human"); + assert_eq!(body["relationship"], "self"); + assert_eq!(body["wrapped_profile_dek"], "base64-wrapped-profile-dek"); + + // List now has one. + let (_, list_body) = common::send_json(&app, "GET", "/api/profiles", None, Some(&token)).await; + assert_eq!(list_body.as_array().unwrap().len(), 1); + + // Get by id. + let (status, get_body) = common::send_json( + &app, + "GET", + &format!("/api/profiles/{profile_id}"), + None, + Some(&token), + ) + .await; + assert_eq!(status, 200); + assert_eq!(get_body["profile_id"], profile_id); + + // Update the display-name blob + relationship. + let (status, _) = common::send_json( + &app, + "PUT", + &format!("/api/profiles/{profile_id}"), + Some(json!({ + "name_data": "new-name-blob", + "name_iv": "new-iv", + "kind": "pet", + "relationship": "dog", + })), + Some(&token), + ) + .await; + assert_eq!(status, 200, "update should return 200"); + + // Delete. + let (status, _) = common::send_json( + &app, + "DELETE", + &format!("/api/profiles/{profile_id}"), + None, + Some(&token), + ) + .await; + assert_eq!(status, 204, "delete should return 204"); + + // Get now 404s. + let (status, _) = common::send_json( + &app, + "GET", + &format!("/api/profiles/{profile_id}"), + None, + Some(&token), + ) + .await; + assert_eq!(status, 404); + + common::drop_test_db(&db_name).await; +} + +#[tokio::test] +async fn other_user_cannot_access_profile() { + // Ownership isolation: user B must not GET/UPDATE/DELETE user A's profile. + let (app, db_name) = require_app!(common::app_for_test().await); + let token_a = register(&app, &unique_email(), "supersecret").await; + let token_b = register(&app, &unique_email(), "supersecret").await; + + // A creates a profile. + let (_, body) = common::send_json( + &app, + "POST", + "/api/profiles", + Some(create_body("human", "child")), + Some(&token_a), + ) + .await; + let profile_id = body["profile_id"].as_str().unwrap().to_string(); + + // B cannot GET it (404, because ownership filter excludes it). + let (status, _) = common::send_json( + &app, + "GET", + &format!("/api/profiles/{profile_id}"), + None, + Some(&token_b), + ) + .await; + assert_eq!(status, 404, "B must not read A's profile"); + + // B cannot UPDATE it. + let (status, _) = common::send_json( + &app, + "PUT", + &format!("/api/profiles/{profile_id}"), + Some(json!({ + "name_data": "x", "name_iv": "y", "kind": "human", "relationship": "self" + })), + Some(&token_b), + ) + .await; + assert_eq!(status, 404, "B must not update A's profile"); + + // B cannot DELETE it. + let (status, _) = common::send_json( + &app, + "DELETE", + &format!("/api/profiles/{profile_id}"), + None, + Some(&token_b), + ) + .await; + assert_eq!(status, 404, "B must not delete A's profile"); + + // A still sees it intact. + let (status, _) = common::send_json( + &app, + "GET", + &format!("/api/profiles/{profile_id}"), + None, + Some(&token_a), + ) + .await; + assert_eq!(status, 200, "A's profile must be untouched"); + + common::drop_test_db(&db_name).await; +} + +#[tokio::test] +async fn register_with_default_profile_creates_self_profile() { + // When register carries default_wrapped_profile_dek, the self profile is + // auto-created and shows up in GET /api/profiles. + let (app, db_name) = require_app!(common::app_for_test().await); + let email = unique_email(); + + let (_, body) = common::send_json( + &app, + "POST", + "/api/auth/register", + Some(json!({ + "email": email, + "username": "tester", + "password": "supersecret", + "default_profile_name_data": "name-blob", + "default_profile_name_iv": "name-iv", + "default_wrapped_profile_dek": "dek-blob", + "default_wrapped_profile_dek_iv": "dek-iv", + })), + None, + ) + .await; + let token = body["token"].as_str().unwrap().to_string(); + + let (_, list_body) = common::send_json(&app, "GET", "/api/profiles", None, Some(&token)).await; + let arr = list_body.as_array().unwrap(); + assert_eq!(arr.len(), 1, "default self profile should exist"); + assert_eq!(arr[0]["relationship"], "self"); + assert_eq!(arr[0]["kind"], "human"); + assert_eq!(arr[0]["wrapped_profile_dek"], "dek-blob"); + // Deterministic id contract: profile_. + assert!(arr[0]["profile_id"] + .as_str() + .unwrap() + .starts_with("profile_")); + + common::drop_test_db(&db_name).await; +} diff --git a/backend/tests/rekey_tests.rs b/backend/tests/rekey_tests.rs new file mode 100644 index 0000000..5bb4893 --- /dev/null +++ b/backend/tests/rekey_tests.rs @@ -0,0 +1,382 @@ +//! Hard-revoke / re-key integration tests (Phase C). +//! +//! Verifies POST /api/profiles/:id/rekey: rotating the profile's wrapped DEK, +//! upserting submitted recipient envelopes (fresh ECDH wraps), and hard- +//! deleting any current recipient NOT in the submitted list. Wire-level only +//! (opaque blobs — the server never inspects them, so the tests don't need +//! real crypto). +//! +//! Requires a live MongoDB; skips gracefully otherwise. + +mod common; + +use serde_json::{json, Value}; + +macro_rules! require_app { + ($app:expr) => { + match $app { + Some(x) => x, + None => { + eprintln!("[integration] skipped (MongoDB unavailable)"); + return; + } + } + }; +} + +fn unique_email() -> String { + format!("test_{}@example.com", uuid::Uuid::new_v4()) +} + +/// Register a fully-set-up user (identity key + default self profile). +async fn register_full(app: &axum::Router, email: &str) -> Value { + let (status, body) = common::send_json( + app, + "POST", + "/api/auth/register", + Some(json!({ + "email": email, + "username": email, + "password": "supersecret", + "identity_public_key": format!("pub-{email}"), + "default_profile_name_data": "n", + "default_profile_name_iv": "i", + "default_wrapped_profile_dek": "owner-dek-v1", + "default_wrapped_profile_dek_iv": "owner-dek-iv-v1", + })), + None, + ) + .await; + assert_eq!(status, 201, "register_full failed, body: {body}"); + body +} + +fn token(body: &Value) -> String { + body["token"].as_str().unwrap().to_string() +} + +fn self_profile_id(body: &Value) -> String { + format!("profile_{}", body["user_id"].as_str().unwrap()) +} + +/// Owner shares profile to recipient_email; returns the recipient's user_id +/// (looked up from the share listing afterward). +async fn share_to( + app: &axum::Router, + owner_token: &str, + profile_id: &str, + recipient_email: &str, +) -> String { + let (status, _) = common::send_json( + app, + "POST", + &format!("/api/profiles/{profile_id}/shares"), + Some(json!({ + "recipient_email": recipient_email, + "ephemeral_public_key": format!("eph-{recipient_email}"), + "wrapped_profile_dek": format!("wrap-{recipient_email}"), + "wrapped_profile_dek_iv": "iv", + "permissions": ["read"], + })), + Some(owner_token), + ) + .await; + assert_eq!(status, 201, "share_to failed"); + // Look up the recipient's user_id from the listing. + let (_, listing) = common::send_json( + app, + "GET", + &format!("/api/profiles/{profile_id}/shares"), + None, + Some(owner_token), + ) + .await; + listing + .as_array() + .unwrap() + .iter() + .find(|s| s["recipient_email"] == recipient_email) + .unwrap()["recipient_user_id"] + .as_str() + .unwrap() + .to_string() +} + +#[tokio::test] +async fn rekey_rotates_owner_share_and_keeps_listed_recipients() { + let (app, db_name) = require_app!(common::app_for_test().await); + let owner_body = register_full(&app, &unique_email()).await; + let owner_token = token(&owner_body); + let profile_id = self_profile_id(&owner_body); + + let recipient_b = unique_email(); + let recipient_c = unique_email(); + register_full(&app, &recipient_b).await; + register_full(&app, &recipient_c).await; + let b_uid = share_to(&app, &owner_token, &profile_id, &recipient_b).await; + let _c_uid = share_to(&app, &owner_token, &profile_id, &recipient_c).await; + + // Owner rekeys: keeps B (fresh envelope), omits C (hard-revoke). + let (status, body) = common::send_json( + &app, + "POST", + &format!("/api/profiles/{profile_id}/rekey"), + Some(json!({ + "new_wrapped_profile_dek": "owner-dek-v2", + "new_wrapped_profile_dek_iv": "owner-dek-iv-v2", + "recipient_envelopes": [{ + "recipient_user_id": b_uid, + "ephemeral_public_key": "eph-b-v2", + "wrapped_profile_dek": "wrap-b-v2", + "wrapped_profile_dek_iv": "iv-v2", + }], + })), + Some(&owner_token), + ) + .await; + assert_eq!(status, 200, "rekey should return 200, body: {body}"); + // Owner share rotated. + assert_eq!(body["wrapped_profile_dek"], "owner-dek-v2"); + assert_eq!(body["wrapped_profile_dek_iv"], "owner-dek-iv-v2"); + // Retained = [B]. + let retained: Vec<&str> = body["retained_recipient_user_ids"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert_eq!(retained, vec![b_uid.as_str()]); + + // The profile's stored owner-share reflects v2. + let (_, profile_body) = common::send_json( + &app, + "GET", + &format!("/api/profiles/{profile_id}"), + None, + Some(&owner_token), + ) + .await; + assert_eq!(profile_body["wrapped_profile_dek"], "owner-dek-v2"); + + common::drop_test_db(&db_name).await; +} + +#[tokio::test] +async fn rekey_hard_revokes_omitted_recipient() { + let (app, db_name) = require_app!(common::app_for_test().await); + let owner_body = register_full(&app, &unique_email()).await; + let owner_token = token(&owner_body); + let profile_id = self_profile_id(&owner_body); + + let recipient_b = unique_email(); + let recipient_c = unique_email(); + let b_body = register_full(&app, &recipient_b).await; + let c_body = register_full(&app, &recipient_c).await; + let b_token = token(&b_body); + let c_token = token(&c_body); + let b_uid = share_to(&app, &owner_token, &profile_id, &recipient_b).await; + let _c_uid = share_to(&app, &owner_token, &profile_id, &recipient_c).await; + + // Before rekey: both recipients see the profile in shared-with-me. + let (_, shared_before) = common::send_json( + &app, + "GET", + "/api/profiles/shared-with-me", + None, + Some(&c_token), + ) + .await; + assert_eq!( + shared_before.as_array().unwrap().len(), + 1, + "C should see the shared profile before rekey" + ); + + // Rekey keeping only B. + let (status, _) = common::send_json( + &app, + "POST", + &format!("/api/profiles/{profile_id}/rekey"), + Some(json!({ + "new_wrapped_profile_dek": "owner-dek-v2", + "new_wrapped_profile_dek_iv": "owner-dek-iv-v2", + "recipient_envelopes": [{ + "recipient_user_id": b_uid, + "ephemeral_public_key": "eph-b-v2", + "wrapped_profile_dek": "wrap-b-v2", + "wrapped_profile_dek_iv": "iv-v2", + }], + })), + Some(&owner_token), + ) + .await; + assert_eq!(status, 200); + + // B (kept) still sees it, with the rotated envelope. + let (_, shared_b) = common::send_json( + &app, + "GET", + "/api/profiles/shared-with-me", + None, + Some(&b_token), + ) + .await; + let arr_b = shared_b.as_array().unwrap(); + assert_eq!(arr_b.len(), 1, "B sees exactly the one shared profile"); + assert_eq!( + arr_b[0]["wrapped_profile_dek"], "wrap-b-v2", + "B's envelope rotated" + ); + + // C (omitted) no longer sees it. + let (_, shared_c) = common::send_json( + &app, + "GET", + "/api/profiles/shared-with-me", + None, + Some(&c_token), + ) + .await; + assert_eq!( + shared_c.as_array().unwrap().len(), + 0, + "C must be hard-revoked from shared-with-me" + ); + // And the share-gate 404s C on direct read. + let (status, _) = common::send_json( + &app, + "GET", + &format!("/api/profiles/{profile_id}"), + None, + Some(&c_token), + ) + .await; + assert_eq!( + status, 404, + "C must be denied by the gate after hard revoke" + ); + + common::drop_test_db(&db_name).await; +} + +#[tokio::test] +async fn rekey_rejects_envelope_for_recipient_with_no_share() { + let (app, db_name) = require_app!(common::app_for_test().await); + let owner_body = register_full(&app, &unique_email()).await; + let owner_token = token(&owner_body); + let profile_id = self_profile_id(&owner_body); + + // A recipient who was never shared with. + let stranger_body = register_full(&app, &unique_email()).await; + let stranger_uid = stranger_body["user_id"].as_str().unwrap().to_string(); + + let (status, body) = common::send_json( + &app, + "POST", + &format!("/api/profiles/{profile_id}/rekey"), + Some(json!({ + "new_wrapped_profile_dek": "owner-dek-v2", + "new_wrapped_profile_dek_iv": "owner-dek-iv-v2", + "recipient_envelopes": [{ + "recipient_user_id": stranger_uid, + "ephemeral_public_key": "x", + "wrapped_profile_dek": "y", + "wrapped_profile_dek_iv": "z", + }], + })), + Some(&owner_token), + ) + .await; + assert_eq!( + status, 400, + "envelope for a recipient with no share must be rejected: {body}" + ); + + common::drop_test_db(&db_name).await; +} + +#[tokio::test] +async fn rekey_rejects_non_owner() { + let (app, db_name) = require_app!(common::app_for_test().await); + let owner_body = register_full(&app, &unique_email()).await; + let profile_id = self_profile_id(&owner_body); + + let other_token = token(®ister_full(&app, &unique_email()).await); + + let (status, _) = common::send_json( + &app, + "POST", + &format!("/api/profiles/{profile_id}/rekey"), + Some(json!({ + "new_wrapped_profile_dek": "x", + "new_wrapped_profile_dek_iv": "y", + "recipient_envelopes": [], + })), + Some(&other_token), + ) + .await; + assert_eq!(status, 404, "non-owner rekey must be rejected"); + + // Profile is unchanged. + let (_, profile_body) = common::send_json( + &app, + "GET", + &format!("/api/profiles/{profile_id}"), + None, + Some(token(&owner_body).as_str()), + ) + .await; + assert_eq!(profile_body["wrapped_profile_dek"], "owner-dek-v1"); + + common::drop_test_db(&db_name).await; +} + +#[tokio::test] +async fn rekey_with_empty_envelopes_revokes_all_recipients() { + // Keeping nobody = hard-revoke everyone (e.g. suspected compromise). + let (app, db_name) = require_app!(common::app_for_test().await); + let owner_body = register_full(&app, &unique_email()).await; + let owner_token = token(&owner_body); + let profile_id = self_profile_id(&owner_body); + + let recipient_b = unique_email(); + register_full(&app, &recipient_b).await; + share_to(&app, &owner_token, &profile_id, &recipient_b).await; + + let (status, body) = common::send_json( + &app, + "POST", + &format!("/api/profiles/{profile_id}/rekey"), + Some(json!({ + "new_wrapped_profile_dek": "owner-dek-v2", + "new_wrapped_profile_dek_iv": "owner-dek-iv-v2", + "recipient_envelopes": [], + })), + Some(&owner_token), + ) + .await; + assert_eq!( + status, 200, + "rekey with no envelopes should succeed: {body}" + ); + assert_eq!( + body["retained_recipient_user_ids"] + .as_array() + .unwrap() + .len(), + 0 + ); + + // The shares listing is now empty. + let (_, listing) = common::send_json( + &app, + "GET", + &format!("/api/profiles/{profile_id}/shares"), + None, + Some(&owner_token), + ) + .await; + assert_eq!(listing.as_array().unwrap().len(), 0); + + common::drop_test_db(&db_name).await; +} diff --git a/backend/tests/share_tests.rs b/backend/tests/share_tests.rs new file mode 100644 index 0000000..05d84a1 --- /dev/null +++ b/backend/tests/share_tests.rs @@ -0,0 +1,456 @@ +//! Profile-sharing integration tests (Phase B). +//! +//! Exercises the X25519 envelope endpoints and the share-gate: an owner +//! shares a profile to a recipient; the recipient sees it in +//! /profiles/shared-with-me and can read its data; revoke cuts off access. +//! All wrapped blobs are opaque strings — the server never inspects them, so +//! the test doesn'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()) +} + +/// URL-encode a value for a query string (emails contain '@'). +fn q(email: &str) -> String { + email.replace('@', "%40") +} + +/// Register a fully-set-up user (identity public key + a default self profile) +/// and return the access token. The owner profile is `profile_`. +async fn register_full(app: &axum::Router, email: &str) -> String { + let (status, body) = common::send_json( + app, + "POST", + "/api/auth/register", + Some(json!({ + "email": email, + "username": email, + "password": "supersecret", + "identity_public_key": format!("pubkey-{email}"), + "default_profile_name_data": "name-blob", + "default_profile_name_iv": "name-iv", + "default_wrapped_profile_dek": "dek-blob", + "default_wrapped_profile_dek_iv": "dek-iv", + })), + None, + ) + .await; + assert_eq!(status, 201, "register_full failed, body: {body}"); + body["token"].as_str().unwrap().to_string() +} + +/// Register a user with NO identity public key (simulates a pre-A1 account). +async fn register_no_key(app: &axum::Router, email: &str) -> String { + let (status, body) = common::send_json( + app, + "POST", + "/api/auth/register", + Some(json!({ "email": email, "username": email, "password": "supersecret" })), + None, + ) + .await; + assert_eq!(status, 201, "register_no_key failed, body: {body}"); + body["token"].as_str().unwrap().to_string() +} + +/// The owner's self profile id (deterministic contract). +fn self_profile_id(owner_token_response: &Value) -> String { + format!( + "profile_{}", + owner_token_response["user_id"].as_str().unwrap() + ) +} + +#[tokio::test] +async fn public_key_lookup_returns_recipient_identity_key() { + let (app, db_name) = require_app!(common::app_for_test().await); + let owner_email = unique_email(); + let _token = register_full(&app, &owner_email).await; + + let (status, body) = common::send_json( + &app, + "GET", + &format!("/api/users/public-key?email={}", q(&owner_email)), + None, + None, + ) + .await; + assert_eq!(status, 200, "public-key lookup, body: {body}"); + assert_eq!(body["identity_public_key"], format!("pubkey-{owner_email}")); + assert!(!body["user_id"].as_str().unwrap().is_empty()); + + common::drop_test_db(&db_name).await; +} + +#[tokio::test] +async fn public_key_lookup_404s_for_unknown_or_keyless_user() { + let (app, db_name) = require_app!(common::app_for_test().await); + let keyless_email = unique_email(); + let _t = register_no_key(&app, &keyless_email).await; + + // Unknown address. + let (status, _) = common::send_json( + &app, + "GET", + "/api/users/public-key?email=does-not-exist%40example.com", + None, + None, + ) + .await; + assert_eq!(status, 404); + + // Existing user but no key. + let (status, _) = common::send_json( + &app, + "GET", + &format!("/api/users/public-key?email={}", q(&keyless_email)), + None, + None, + ) + .await; + assert_eq!(status, 404); + + common::drop_test_db(&db_name).await; +} + +#[tokio::test] +async fn owner_shares_recipient_reads_owner_revokes() { + let (app, db_name) = require_app!(common::app_for_test().await); + let owner_email = unique_email(); + let recipient_email = unique_email(); + + // Register owner + recipient (both with identity keys + self profiles). + let (_, owner_body) = common::send_json( + &app, + "POST", + "/api/auth/register", + Some(json!({ + "email": owner_email, + "username": owner_email, + "password": "supersecret", + "identity_public_key": "owner-pub", + "default_profile_name_data": "n", "default_profile_name_iv": "i", + "default_wrapped_profile_dek": "d", "default_wrapped_profile_dek_iv": "v", + })), + None, + ) + .await; + let owner_token = owner_body["token"].as_str().unwrap().to_string(); + let owner_profile = self_profile_id(&owner_body); + + let recipient_token = register_full(&app, &recipient_email).await; + + // Owner shares their self profile to the recipient. + let (status, _) = common::send_json( + &app, + "POST", + &format!("/api/profiles/{owner_profile}/shares"), + Some(json!({ + "recipient_email": recipient_email, + "ephemeral_public_key": "ephemeral-pub", + "wrapped_profile_dek": "wrapped-dek", + "wrapped_profile_dek_iv": "wrapped-iv", + "permissions": ["read"], + })), + Some(&owner_token), + ) + .await; + assert_eq!(status, 201, "share create should return 201"); + + // Recipient sees it under /profiles/shared-with-me. + let (status, body) = common::send_json( + &app, + "GET", + "/api/profiles/shared-with-me", + None, + Some(&recipient_token), + ) + .await; + assert_eq!(status, 200, "shared-with-me, body: {body}"); + let arr = body.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["profile_id"], owner_profile); + assert_eq!(arr[0]["ephemeral_public_key"], "ephemeral-pub"); + assert_eq!(arr[0]["wrapped_profile_dek"], "wrapped-dek"); + + // Recipient can GET the shared profile (share-aware GET). + let (status, _) = common::send_json( + &app, + "GET", + &format!("/api/profiles/{owner_profile}"), + None, + Some(&recipient_token), + ) + .await; + assert_eq!(status, 200, "recipient should read shared profile"); + + // Recipient can list the owner's medications for that profile (the owner + // has none, but the share-gate must admit the request rather than 404). + let (status, body) = common::send_json( + &app, + "GET", + &format!("/api/medications?profile_id={owner_profile}"), + None, + Some(&recipient_token), + ) + .await; + assert_eq!(status, 200, "recipient meds read, body: {body}"); + assert_eq!(body.as_array().unwrap().len(), 0); + + // Recipient attempts a write to the shared profile. The medication create + // handler (a) doesn't enforce ownership on writes yet (issue #12) and + // (b) returns 200 OK (not 201) on success. So the write currently succeeds + // but is attributed to the RECIPIENT's user_id, not the owner — meaning it + // does NOT pollute the owner's view of that profile. Accept 200 (current, + // #12 not fixed) or 403 (once #12 lands). Either way the owner's records + // must be untouched. + let (write_status, _body) = common::send_json( + &app, + "POST", + "/api/medications", + Some(json!({ + "profile_id": owner_profile, + "encrypted_data": { "data": "x", "iv": "y" }, + "active": true, + })), + Some(&recipient_token), + ) + .await; + assert!( + write_status == 200 || write_status == 403, + "write outcome: {write_status}" + ); + let (status, owner_meds) = common::send_json( + &app, + "GET", + &format!("/api/medications?profile_id={owner_profile}"), + None, + Some(&owner_token), + ) + .await; + assert_eq!(status, 200); + assert_eq!( + owner_meds.as_array().unwrap().len(), + 0, + "owner's data untouched" + ); + + // Owner revokes. + // Look up the recipient's user id from the shares listing. + let (_, shares) = common::send_json( + &app, + "GET", + &format!("/api/profiles/{owner_profile}/shares"), + None, + Some(&owner_token), + ) + .await; + let recipient_uid = shares[0]["recipient_user_id"].as_str().unwrap().to_string(); + + let (status, _) = common::send_json( + &app, + "DELETE", + &format!("/api/profiles/{owner_profile}/shares/{recipient_uid}"), + None, + Some(&owner_token), + ) + .await; + assert_eq!(status, 204, "revoke should return 204"); + + // Recipient now loses read access. + let (status, _) = common::send_json( + &app, + "GET", + &format!("/api/profiles/{owner_profile}"), + None, + Some(&recipient_token), + ) + .await; + assert_eq!(status, 404, "revoked recipient should get 404"); + let (status, body) = common::send_json( + &app, + "GET", + "/api/profiles/shared-with-me", + None, + Some(&recipient_token), + ) + .await; + assert_eq!(status, 200); + assert_eq!( + body.as_array().unwrap().len(), + 0, + "shared-with-me now empty" + ); + + common::drop_test_db(&db_name).await; +} + +#[tokio::test] +async fn share_rejects_bad_recipient_states() { + let (app, db_name) = require_app!(common::app_for_test().await); + let owner_email = unique_email(); + + let (_, owner_body) = common::send_json( + &app, + "POST", + "/api/auth/register", + Some(json!({ + "email": owner_email, "username": owner_email, "password": "supersecret", + "identity_public_key": "owner-pub", + "default_profile_name_data": "n", "default_profile_name_iv": "i", + "default_wrapped_profile_dek": "d", "default_wrapped_profile_dek_iv": "v", + })), + None, + ) + .await; + let owner_token = owner_body["token"].as_str().unwrap().to_string(); + let owner_profile = self_profile_id(&owner_body); + + // Share to a nonexistent recipient → 404. + let (status, _) = common::send_json( + &app, + "POST", + &format!("/api/profiles/{owner_profile}/shares"), + Some(json!({ + "recipient_email": "ghost@example.com", + "ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v", + })), + Some(&owner_token), + ) + .await; + assert_eq!(status, 404, "share to ghost should 404"); + + // Share to a user without an identity key → 404. + let keyless_email = unique_email(); + let _t = register_no_key(&app, &keyless_email).await; + let (status, _) = common::send_json( + &app, + "POST", + &format!("/api/profiles/{owner_profile}/shares"), + Some(json!({ + "recipient_email": keyless_email, + "ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v", + })), + Some(&owner_token), + ) + .await; + assert_eq!(status, 404, "share to keyless user should 404"); + + // Share to self → 400. + let (status, _) = common::send_json( + &app, + "POST", + &format!("/api/profiles/{owner_profile}/shares"), + Some(json!({ + "recipient_email": owner_email, + "ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v", + })), + Some(&owner_token), + ) + .await; + assert_eq!(status, 400, "share to self should 400"); + + // Non-owner cannot create a share for someone else's profile. + let other_token = register_full(&app, &unique_email()).await; + let (status, _) = common::send_json( + &app, + "POST", + &format!("/api/profiles/{owner_profile}/shares"), + Some(json!({ + "recipient_email": unique_email(), + "ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v", + })), + Some(&other_token), + ) + .await; + assert_eq!( + status, 404, + "non-owner share attempt should 404 (profile not found for them)" + ); + + common::drop_test_db(&db_name).await; +} + +#[tokio::test] +async fn expired_share_is_treated_as_absent() { + let (app, db_name) = require_app!(common::app_for_test().await); + + let (_, owner_body) = common::send_json( + &app, + "POST", + "/api/auth/register", + Some(json!({ + "email": unique_email(), "username": "owner", "password": "supersecret", + "identity_public_key": "owner-pub", + "default_profile_name_data": "n", "default_profile_name_iv": "i", + "default_wrapped_profile_dek": "d", "default_wrapped_profile_dek_iv": "v", + })), + None, + ) + .await; + let owner_token = owner_body["token"].as_str().unwrap().to_string(); + let owner_profile = self_profile_id(&owner_body); + + let recipient_email = unique_email(); + let recipient_token = register_full(&app, &recipient_email).await; + + // Share with an expiry in the past. + let (status, _) = common::send_json( + &app, + "POST", + &format!("/api/profiles/{owner_profile}/shares"), + Some(json!({ + "recipient_email": recipient_email, + "ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v", + "expires_at": "2020-01-01T00:00:00Z", + })), + Some(&owner_token), + ) + .await; + assert_eq!(status, 201); + + // Recipient cannot read — expired share is invisible to the gate. + let (status, _) = common::send_json( + &app, + "GET", + &format!("/api/profiles/{owner_profile}"), + None, + Some(&recipient_token), + ) + .await; + assert_eq!(status, 404, "expired share should not grant access"); + let (status, body) = common::send_json( + &app, + "GET", + "/api/profiles/shared-with-me", + None, + Some(&recipient_token), + ) + .await; + assert_eq!(status, 200); + assert_eq!( + body.as_array().unwrap().len(), + 0, + "expired share hidden from list" + ); + + common::drop_test_db(&db_name).await; +} diff --git a/backend/tests/zk_integration_tests.rs b/backend/tests/zk_integration_tests.rs index 1e38c41..50c3c65 100644 --- a/backend/tests/zk_integration_tests.rs +++ b/backend/tests/zk_integration_tests.rs @@ -179,6 +179,7 @@ async fn health_stat_stored_as_ciphertext() { "POST", "/api/health-stats", Some(json!({ + "profile_id": "default", "encrypted_data": { "data": opaque_data, "iv": "aXY=" }, "recorded_at": "2026-07-01T10:00:00Z", })), diff --git a/docs/adr/README.md b/docs/adr/README.md index b8b4e9f..1759ef1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -11,14 +11,15 @@ the documentation reconciliation. They are historical context, not current specs |--------|-------| | [tech-stack-decision.md](./tech-stack-decision.md) | Master stack choice: Rust/Axum backend, React frontend, MongoDB, JWT | | [mongodb-schema-decision.md](./mongodb-schema-decision.md) | Document model + at-rest encryption approach | -| [jwt-authentication-decision.md](./jwt-authentication-decision.md) | JWT access/refresh tokens, recovery phrases | +| [jwt-authentication-decision.md](./jwt-authentication-decision.md) | JWT access/refresh tokens, `token_version` revocation, refresh rotation — *reconciled with code 2026-07-18* | | [frontend-decision-summary.md](./frontend-decision-summary.md) | React (web) + React Native (mobile, future) split | | [state-management-decision.md](./state-management-decision.md) | Client state — *superseded*: decision was Redux Toolkit, **actual code uses Zustand** | | [monorepo-structure.md](./monorepo-structure.md) | Repository layout (`backend/`, `web/`, `mobile/`, `docs/`) | | [backend-deployment-constraints.md](./backend-deployment-constraints.md) | Deployment requirements (Solaria, Docker) | | [mobile-health-frameworks-data.md](./mobile-health-frameworks-data.md) | HealthKit / Health Connect data-type reference (for future mobile work) | | [android-health-connect-data-types.md](./android-health-connect-data-types.md) | Android Health Connect data types | -| [zero-knowledge-encryption.md](./zero-knowledge-encryption.md) | Client-side zero-knowledge encryption (AES-256-GCM, double-PBKDF2, Phase 1) | +| [zero-knowledge-encryption.md](./zero-knowledge-encryption.md) | Client-side zero-knowledge encryption (AES-256-GCM, double-PBKDF2, wrapped-DEK recovery; Phase 1 + Phase 2 implemented) | +| [multi-person-sharing.md](./multi-person-sharing.md) | Per-profile DEK + X25519 envelope for family/caregiver sharing — *Decided, not yet implemented* (issue #3) | > **Note**: Where a decision diverges from the implemented code (e.g. state > management), the code is the source of truth and the ADR is kept only as diff --git a/docs/adr/jwt-authentication-decision.md b/docs/adr/jwt-authentication-decision.md index bf3f59b..8af389d 100644 --- a/docs/adr/jwt-authentication-decision.md +++ b/docs/adr/jwt-authentication-decision.md @@ -1,174 +1,185 @@ -# JWT Authentication Decision Summary +# ADR: JWT Authentication -**Date**: 2026-02-14 -**Decision**: **JWT with Refresh Tokens + Recovery Phrases** +**Status**: Implemented (current code is the source of truth) +**Date**: 2026-02-14 (original decision), 2026-07-18 (reconciled with code) ---- +> This ADR was originally written during Phase 1 research and described several +> things that were never built (bcrypt, Redis, family-role permission claims in +> the JWT, the specific recovery-phrase flow). It has been rewritten to match +> the implementation. The original reasoning is preserved in the "History" +> section at the end. Where code and the old text disagree, **the code wins**. -## Authentication Strategy +## Context -### Primary: JWT (JSON Web Tokens) +Normogen authenticates accounts with email + password and authorizes API +requests with stateless JWTs. This sits *underneath* the zero-knowledge +encryption layer (see [`zero-knowledge-decryption.md`](./zero-knowledge-encryption.md) +and [`encryption.md`](../product/encryption.md)): authentication proves who you +are and lets you fetch your wrapped DEK; the client-side ZK layer then unlocks +your data. The auth system never sees plaintext health data. -**Why JWT?** -- Stateless design scales to 1000+ concurrent connections -- Works perfectly with mobile apps (AsyncStorage) -- No server-side session storage needed -- Easy to scale Axum horizontally +## Decision -### Token Types +### Token model -**Access Token** (15 minutes) -- Used for API requests -- Short-lived for security -- Contains: user_id, email, family_id, permissions +Two JWTs, both HS256-signed with a shared secret from config: -**Refresh Token** (30 days) -- Used to get new access tokens -- Long-lived for convenience -- Stored in MongoDB for revocation -- Rotated on every refresh +| Token | Lifetime | Claims | Purpose | +|---|---|---|---| +| Access | configurable (`JwtConfig.access_token_expiry_minutes`, default **15 min**) | `{sub, exp, iat, user_id, email, token_version}` | Sent on every API request | +| Refresh | configurable (`JwtConfig.refresh_token_expiry_days`, default **30 days**) | `{sub, exp, iat, jti, user_id, token_version}` | Exchange for a new access token | ---- +> Reference: `backend/src/auth/jwt.rs` (`Claims`, `RefreshClaims`, `JwtService`). -## Token Revocation Strategies +The lifetimes are **config-driven**, not hardcoded — the original ADR's "15 min +/ 30 days" are the defaults, not constants. -### 1. Refresh Token Blacklist (Recommended) ⭐ -- Store refresh tokens in MongoDB -- Mark as revoked on logout -- Check on every refresh +### Claims (actual, as implemented) -### 2. Token Versioning -- Include version in JWT claims -- Increment on password change -- Invalidate all tokens when version changes +The earlier ADR listed richer claims (`family_id`, `permissions`, `token_type`). +**These are not in the shipped access token.** The real `Claims` struct is: -### 3. Access Token Blacklist (Optional) -- Store revoked access tokens in Redis -- For immediate revocation -- Auto-expires with TTL +```rust +pub struct Claims { // access token + pub sub: String, // = user_id (ObjectId hex) + pub exp: usize, + pub iat: usize, + pub user_id: String, + pub email: String, + pub token_version: i32, +} ---- - -## Refresh Token Pattern - -### Token Rotation (Security Best Practice) ⭐ - -**Flow**: -1. Client sends refresh_token -2. Server verifies refresh_token (not revoked, not expired) -3. Server generates new access_token -4. Server generates new refresh_token -5. Server revokes old refresh_token -6. Server returns new tokens - -**Why?** Prevents reuse of stolen refresh tokens - ---- - -## Zero-Knowledge Password Recovery - -### Recovery Phrases (from encryption.md) - -**Registration**: -1. Client generates recovery phrase (random 32 bytes) -2. Client encrypts recovery phrase with password -3. Client sends: email, password hash, encrypted recovery phrase -4. Server stores: email, password hash, encrypted recovery phrase - -**Password Recovery**: -1. User requests recovery (enters email) -2. Server returns: encrypted recovery phrase -3. Client decrypts with recovery key (user enters manually) -4. User enters new password -5. Client re-encrypts recovery phrase with new password -6. Client sends: new password hash, re-encrypted recovery phrase -7. Server updates: password hash, encrypted recovery phrase, token_version + 1 -8. All existing tokens invalidated (version mismatch) - ---- - -## Family Member Access Control - -### Permissions in JWT - -```typescript -// JWT permissions based on family role -{ - "parent": [ - "read:own_data", - "write:own_data", - "read:family_data", - "write:family_data", - "manage:family_members", - "delete:data" - ], - "child": [ - "read:own_data", - "write:own_data" - ], - "elderly": [ - "read:own_data", - "write:own_data", - "read:family_data" - ] +pub struct RefreshClaims { // refresh token + pub sub: String, + pub exp: usize, + pub iat: usize, + pub jti: String, // unique per token (see below) + pub user_id: String, + pub token_version: i32, } ``` -### Permission Middleware +Note what is **absent** and why: -- Check permissions on protected routes -- Return 403 Forbidden if insufficient permissions -- Works with JWT claims +- **No `family_id` or `permissions` in the token.** Family/permission + enforcement is not implemented at the auth layer (see issue #3 — multi-person + sharing is an open design problem). Putting unenforced claims in the token + would imply protection that doesn't exist. + - (Historical: an orphaned `backend/src/auth/claims.rs` once defined a + duplicate `AccessClaims` struct with `family_id`/`permissions`/`jti`. It + was dead code — never declared as a module, never imported — and has been + removed; see issue #5.) +- **No `token_type` discriminator.** Access and refresh claims are different + structs, so a token can only decode as one or the other — the field is + redundant and was dropped. ---- +### Refresh tokens: persistence, rotation, reuse detection -## Technology Stack +- Refresh tokens are **stored in MongoDB** (`refresh_tokens` collection) — not + Redis (the original ADR listed Redis as an option; it is not used). +- The `jti` claim makes every refresh token unique. Without it, two refresh + tokens issued in the same second for the same user would be byte-identical, + which breaks rotation and reuse detection. (Covered by the + `refresh_tokens_are_unique_even_in_same_second` test in `jwt.rs`.) +- On refresh: validate signature + expiry, confirm the token exists and is not + revoked in the repository, mint a new access + refresh pair, revoke the old + refresh token (rotation). -### Backend (Axum) -- jsonwebtoken 9.x (JWT crate) -- bcrypt 0.15 (password hashing) -- mongodb 3.0 (refresh token storage) -- redis (optional, for access token blacklist) +### Token revocation: versioning + session management -### Client (React Native + React) -- AsyncStorage (token storage) -- axios (API client with JWT interceptor) -- PBKDF2 (password derivation) -- AES-256-GCM (data encryption) +Two mechanisms, both implemented: ---- +1. **`token_version` (global kill-switch).** Stored on the `User` document, + incremented on password change and password recovery. The JWT middleware + (`middleware/auth.rs`) rejects any access token whose `token_version` is + stale against the user's current value. A short-lived `TokenVersionCache` + avoids hitting Mongo on every request. +2. **Session management.** Refresh tokens are tracked as sessions + (`GET /api/sessions`, `DELETE /api/sessions/:id`, `DELETE /api/sessions/all`) + so a user can list and revoke individual logins. -## Implementation Timeline +### Password handling -- **Week 1**: Basic JWT (login, register, middleware) -- **Week 1-2**: Refresh tokens (storage, rotation) -- **Week 2**: Token revocation (blacklist, versioning) -- **Week 2-3**: Password recovery (recovery phrases) -- **Week 3**: Family access control (permissions) -- **Week 3-4**: Security hardening (rate limiting, HTTPS) +- **PBKDF2** (not bcrypt — the original ADR mentioned bcrypt), via the `pbkdf2` + crate with a per-password random salt. (`backend/src/auth/password.rs`) +- Under the ZK model, what the server hashes is the **auth secret** — a + base64'd PBKDF2 derivation of the user's password produced client-side — not + the raw password. The raw password never leaves the browser. See + [`encryption.md`](../product/encryption.md) §2. -**Total**: 3-4 weeks +### Password recovery ---- +Recovery is handled by the **zero-knowledge wrapped-DEK** mechanism, *not* by +the "client encrypts the recovery phrase with the password" scheme the original +ADR described. The client derives a recovery KEK from the recovery phrase, +unwraps the DEK from the recovery-wrapped form, and re-wraps it under the new +password. See [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md) +§"Phase 2: Wrapped-DEK Recovery". The server bumps `token_version` on recovery, +invalidating all prior tokens. -## Next Steps +### What protects what -1. Implement basic JWT service in Axum -2. Create MongoDB schema for users and refresh tokens -3. Implement login/register/refresh/logout handlers -4. Create JWT middleware for protected routes -5. Implement token revocation (blacklist + versioning) -6. Integrate password recovery (from encryption.md) -7. Implement family access control (permissions) -8. Test entire authentication flow -9. Create client-side authentication (React Native + React) +Auth + the ciphertext store are defended server-side; plaintext health data is +never on the server to protect: ---- +| Layer | Mechanism | +|---|---| +| Password / auth secret | PBKDF2 + per-password salt | +| Tokens | HS256 signature, short access TTL, refresh rotation + `jti` reuse detection | +| Revocation | `token_version` (global) + session list (per-login) | +| Brute force | Account lockout (5 attempts → exponential backoff) + rate limiting | +| Forensics | Audit logging (auth attempts, authz checks) | + +> Reference: `backend/src/auth/`, `backend/src/security/`, `backend/src/middleware/`. + +## Consequences + +### Positive + +- Stateless access tokens scale horizontally (no DB lookup per request except + the cached version check). +- `token_version` gives instant global revocation on password change/recovery + without maintaining a blocklist of outstanding tokens. +- Refresh rotation + `jti` detects token theft: a rotated-away token being + presented again signals reuse. + +### Negative / costs + +- `token_version` revocation is **coarse** — it kills *all* of a user's + sessions at once. Per-session revocation is only available via the session + list (refresh-token deletion). +- HS256 with a shared secret means the secret is sensitive infrastructure; a + key rotation requires invalidating all tokens (acceptable for the deployment + model, but worth noting vs. asymmetric RS/ES keys). + +## Open items + +- ~~Remove `backend/src/auth/claims.rs` (dead `AccessClaims`) or wire it up + intentionally.~~ **Done** — the orphaned file has been deleted (issue #5, + PR #). The live `Claims` / `RefreshClaims` structs in + `backend/src/auth/jwt.rs` are the only ones. +- When issue #3 (multi-person sharing) is decided, revisit whether family / + share authorization belongs in the JWT or is enforced per-request against a + shares collection. + +## History (original 2026-02-14 decision, superseded where noted) + +The original ADR proposed: JWT with refresh tokens; bcrypt; optional Redis for +access-token blacklists; recovery by client-encrypting the recovery phrase with +the password; and `family_id`/`permissions` claims with parent/child/elderly +role matrices. Of these, only **JWT with refresh tokens + rotation** was built +as described. Password hashing uses PBKDF2 (not bcrypt), there is no Redis, +recovery uses the wrapped-DEK scheme (not phrase-encryption), and the family/ +permission claims were never wired into the real `Claims`. This section is kept +as a record of the original reasoning; the sections above are authoritative. ## References -- [Comprehensive JWT Research](./2026-02-14-jwt-authentication-research.md) -- [Normogen Encryption Guide](../encryption.md) -- [JWT RFC 7519](https://tools.ietf.org/html/rfc7519) -- [Axum JWT Guide](https://docs.rs/axum/latest/axum/) -- [OWASP JWT Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html) +- Code: `backend/src/auth/jwt.rs`, `backend/src/auth/password.rs`, + `backend/src/middleware/auth.rs`, `backend/src/auth/token_version_cache.rs`, + `backend/src/models/refresh_token.rs`, `backend/src/models/session.rs` +- Related ADRs: [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md), + [`multi-person-sharing.md`](./multi-person-sharing.md) (draft) +- Product doc: [`encryption.md`](../product/encryption.md) +- Related issues: [#3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3), + [#4](https://gitea.soliverez.com.ar/alvaro/normogen/issues/4) diff --git a/docs/adr/multi-person-sharing.md b/docs/adr/multi-person-sharing.md new file mode 100644 index 0000000..e2e1825 --- /dev/null +++ b/docs/adr/multi-person-sharing.md @@ -0,0 +1,336 @@ +# ADR: Multi-Person Sharing Under Zero-Knowledge + +**Status**: Decided (not yet implemented) +**Date**: 2026-07-18 +**Discussion**: [issue #3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3) +**Related**: [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md) (the single-user ZK model this extends) + +> Records the design decision and its rationale ahead of implementation, +> following the pattern of `zero-knowledge-encryption.md`. The five original +> open questions were resolved on 2026-07-18 (see "Open questions" section). +> Two deferred items remain (pubkey verification mechanism; admin re-key +> quorum) — neither blocks implementation. All crypto primitives named here +> are available in the browser's Web Crypto API. + +--- + +## Context + +Normogen's core product vision (see +[`docs/product/PERSONA_AND_FAMILY_MANAGEMENT.md`](../product/PERSONA_AND_FAMILY_MANAGEMENT.md)) +is multi-person health data: a parent manages a child's medications and shares +them with a spouse or caregiver. The existing zero-knowledge model +(`zero-knowledge-encryption.md`) is single-user: **all of a user's data is +encrypted under one DEK that only that user can unwrap.** There is no mechanism +for a second user to decrypt any of it. As called out in issue #3, the current +`Share` model + `/api/shares` routes store ACL-style permission records but +cannot grant decryption access to another account — every medication blob is +under the owner's DEK, and the recipient has a different one. + +The product requirements that constrain this design (confirmed in issue #3): + +1. **Parents and spouses have separate logins**, and may belong to *different* + families. Divorced parents with their own families must be able to share the + profile of their common child **and nothing else**. +2. **Recipients always have Normogen accounts.** (One-way document dumps to + non-accounts — e.g. a physician via an encrypted PDF — is a separate, + simpler feature, out of scope here.) +3. **Revocation is a hard requirement.** Cutting off a caregiver must take + effect. +4. **The server operator is fully untrusted.** Key exchange cannot rely on the + server brokering symmetric secrets. + +Additional use cases from issue #3 that the model must accommodate: + +- **Profile graduation.** A child profile eventually becomes an independent + user with their own login, who may then revoke the parents' access. +- **Pets.** A profile may be an animal (medications, labs, etc.), not a human. + +## Decision + +Implement **per-profile DEKs with an X25519 asymmetric envelope** for sharing, +keeping the server a fully blind store. Concretely: + +### 1. Key hierarchy + +``` +password KEK ──wraps──▶ account DEK ──wraps each──▶ profile DEK ──encrypts──▶ profile data + │ (one per profile) + └──also wraps──▶ account X25519 private key + (account X25519 public key stored plaintext) +``` + +- **Account DEK**: random AES-256-GCM key, wrapped under the password KEK + (unchanged from today). At login the client unwraps it and holds it in + memory. +- **Profile DEK**: a *new* random AES-256-GCM key **per profile**. All data + belonging to a profile (its medications, appointments, etc.) is encrypted + under the profile DEK, not the account DEK. Each profile DEK is wrapped under + the account DEK and stored on the server, indexed by `profileId`. +- **Account X25519 keypair**: generated at registration. The **private** half + is wrapped under the account DEK (so it unlocks at login); the **public** + half is stored in plaintext — public keys are not secret. + +This adds one tier to today's flat `account DEK → data`. Rationale in +Consequences (§3-tier vs flat). + +### 2. Ownership and access + +- Each **profile** has exactly one **owning account** at a time. The owner + holds the profile DEK wrapped to their account DEK (the *owner share*). +- **Shared access** = the profile DEK wrapped to another account's X25519 + public key. Stored on the server as a *recipient share*, indexed by + `(profileId, recipientUserId)`. +- To **read** a shared profile, the recipient's client (at login) unwraps their + X25519 private key, ECDH-derives a wrapping key against the share's ephemeral + public key, and unwraps the profile DEK. + +This is the standard asymmetric-envelope pattern (cf. Signal, `age`, +Tarsnap). The server only ever stores wrapped blobs and public keys. + +### 3. Sharing flow (owner → recipient) + +Client-side only, server untrusted: + +1. Owner's client looks up the **recipient's X25519 public key** + (`GET /api/users/:id/public-key`, new). +2. Owner's client unwraps the target profile's DEK (via their account DEK). +3. Generate an **ephemeral X25519 keypair** for this share. +4. ECDH(ephemeral priv, recipient pub) → AES-GCM **wrapping key**. +5. Wrap the profile DEK under that wrapping key → `{ciphertext, iv, ephemeralPubKey}`. +6. `POST /api/profiles/:id/shares` with `{recipientUserId, wrappedDek, ephemeralPubKey, permissions, expiresAt?}`. +7. Server stores the share record. It cannot decrypt it. + +### 4. Revocation + +Two layers, in order of strength: + +- **Soft revoke** (cheap): delete or deactivate the recipient share record. The + recipient's client no longer receives the wrapped DEK from the server, so it + cannot decrypt **new reads**. Caveat: a malicious client that cached the + profile DEK before revocation keeps decrypting data it already fetched. +- **Hard revoke / re-key** (strong): owner generates a **new profile DEK**, + re-encrypts all of the profile's data under it, re-wraps to the owner share + and to all *still-valid* recipient shares. A revoked recipient's cached old + DEK now fails to decrypt any future fetch. Required for the "kid revokes + parent" graduation case where you must assume the parent may have cached + data. + +Hard revocation is the answer to requirement (3). Re-keying is expensive +(linear in profile data size) so it is opt-in / triggered explicitly, not the +default for every revoke. + +### 5. Profile model changes + +To support pets and graduation, the current `role: "patient"` string becomes: + +``` +kind: "human" | "pet" // distinguishes people from animals +relationship: string // freeform: "self", "child", "spouse", "pet", ... +``` + +Ownership-transfer (graduation) is **not new crypto** — it is the existing +share machinery plus an `owner_account_id` field swap: + +1. New adult account (the graduated child) generates its X25519 keypair. +2. Old owner wraps the profile DEK to the new account's pubkey as a share, and + marks that share as the new **owner share**. +3. Old owner's access is downgraded to (or removed as) a normal recipient share. +4. The new owner can revoke any remaining parent shares via soft/hard revoke. + +### 5b. Use case: elderly parent cared for by children + +A key motivation for the profile/account split is that **profile ownership and +account login are independent.** The elderly-care scenario has three setups, +all covered: + +- **Parent self-manages, shares to children.** Parent owns their own account + and `self` profile; creates recipient shares to each child. Standard + share-to-another-account flow. Any party can revoke. +- **Managing child holds a dedicated parent account.** A separate account + exists for the parent (the identity of record), but the managing child holds + or controls the password and shares the profile to siblings. Cleaner than + embedding the parent as a profile inside the child's account, and preserves + the "take over later" path: if the parent becomes able (or, conversely, + passes away), it's the same ownership machinery. +- **Parent modeled as a profile inside the managing child's account.** No + separate login for the parent; the child owns the profile and shares it. + Simplest, but the parent has no independent identity and cannot revoke + themselves. Recommend against this when the parent is or may become an + independent actor. + +**Death/incapacity of the sole-account owner** is the one wrinkle that needs +an explicit decision: see "Permissions and the admin-share question" below. + +### 5c. Permissions and the admin-share question + +The `permissions` list on a recipient share (Open question 3) raises a +sub-decision: **does an `admin` permission let a recipient act as a quasi-owner +without the original owner?** Specifically: re-share the profile, re-key it +(hard revoke), and add/remove other shares. + +This matters for two scenarios: + +- **Graduation in reverse / death of the owner.** If the parent owns the + account and becomes incapacitated, no child can write, re-share, or revoke — + those require the owner's account DEK, which is gone with the password. +- **Operational delegation.** A primary caregiver wants to delegate admin + tasks without sharing their password. + +**Decision: support an `admin` permission tier.** An admin share can re-share, +re-key, and manage other shares on a profile. This keeps the owner-DEK model +intact (the owner share is still special — it can *transfer* ownership, which +admin cannot) while handling the death/incapacity and delegation cases without +key escrow. Hard revocation (re-key) by an admin rotates the profile DEK and +re-wraps to all *still-valid* shares, same as owner-initiated re-key. + +> ❓ **Open sub-item:** when an admin re-keys, do we require a quorum or a +> second admin's consent? For MVP, no — any admin can act alone, matching how +> ownership transfer works today. Revisit if multi-party control becomes a +> requirement. + +### 6. What happens to the current `Share` model + +The existing `backend/src/models/share.rs` is **resource-level ACL** +(`resource_type`, `resource_id`, `permissions`) and predates encryption. It is +incompatible with this design, which shares at the **profile** level via keys, +not at the resource level via permissions. + +Per issue #3, profile-level sharing covers the family/caregiver need; the +"share one document" need is handled separately (physician PDF feature). So: + +- The current `Share` model and `/api/shares` routes are **retained for now** + but treated as a placeholder (issue #3). They will be either removed or + repurposed; this ADR does not depend on them. +- The new recipient-share records live in a **new collection** (provisional + name `profile_shares`) keyed by `(profileId, recipientUserId)`. Storing + wrapped DEKs, not ACLs. + +### 7. Server surface (new endpoints, indicative) + +- `GET /api/users/:id/public-key` → recipient's X25519 public key (plaintext). +- `GET /api/profiles/me/dek` → the account-wrapped profile DEK (owner unlock). +- `POST /api/profiles` → create profile: client generates profile DEK, wraps to + account DEK, sends wrapped form. Returns `profileId`. +- `GET /api/profiles/:id/shares` → list recipient shares for a profile. +- `POST /api/profiles/:id/shares` → create a recipient share (wrapped DEK). +- `DELETE /api/profiles/:id/shares/:shareId` → soft revoke. +- `POST /api/profiles/:id/rekey` → hard revoke / rotate the profile DEK. + +All endpoints store/return opaque blobs or public keys; none expose a DEK or a +private key. + +--- + +## Consequences + +### Positive + +- **Satisfies all four product constraints.** Per-profile keys enable the + divorced-parents case; hard revoke enables the revocation requirement; the + X25519 envelope keeps the server untrusted. +- **Profiles become first-class subjects of care** — owners, shares, pets, + graduation all fall out naturally. +- **Server stays a blind store.** No new backend crypto; still no `aes`/crypto + crates in `Cargo.toml`. +- **Composition with existing recovery.** The account DEK is still recovered + via the existing password/recovery wrapped-DEK flow; profile DEKs are wrapped + to the account DEK, so recovering the account recovers all profiles. + +### Negative / costs + +- **Adds a key tier.** Three levels instead of one. Complexity in the client + crypto module (`crypto/keys.ts` will grow X25519 + wrap-to-recipient logic). +- **Migration.** Today all data is under one account DEK. Splitting into + per-profile DEKs requires reading, decrypting, re-encrypting, and re-uploading + every document. **There is no real user data yet** (per + `zero-knowledge-encryption.md`), so this is cheap **now** and expensive + later — strong argument for doing the re-key before launch. +- **Hard revocation is expensive.** Re-keying a profile is O(profile data + size). Mitigation: soft revoke by default, hard revoke only when the owner + explicitly requires it (e.g. graduation, suspected compromise). +- **Recipient must have an account** (constraint 2). No magic-link sharing in + this design — deferred to the physician-PDF feature. + +### Security notes + +- **Ephemeral keys per share** prevent cross-share correlation and give + forward secrecy within a share's lifetime (re-keying rotates the DEK). +- **ECDH + AES-GCM** is the Web Crypto idiom; X25519 keys are 32 bytes, shares + are small. +- **Public keys are not authenticated by the server.** The owner must trust + that `GET /users/:id/public-key` returns the right key. Since the server is + untrusted, this is an accepted risk: a malicious server could swap the key + and force a re-share, but cannot decrypt. ❓ Decide whether to add a key + fingerprint the recipient verifies out-of-band. + +## Open questions (❓) — resolved 2026-07-18 + +1. **Key hierarchy depth.** ✅ **Decided: 3-tier** (account DEK → profile DEK → + data). Rationale: account DEK is recovered via the existing wrapped-DEK + recovery flow, so password change only re-wraps the account DEK (O(1)), + not every profile DEK. Profile DEKs are re-wrapped only on profile-level + events (share/revoke/rekey), which are rare. + +2. **Public-key trust.** ✅ **Decided: accept key-swap as DoS-only for now.** + A malicious server can swap a recipient's public key and force a failed + share (DoS) but cannot decrypt. **Future enhancement (not blocking):** add + a mechanism for out-of-band pubkey verification. Candidate approaches to + evaluate when we get there: + - **Key fingerprint / safety number.** Display a short hash of a user's + pubkey that two parties can compare out-of-band (cf. Signal safety + numbers). Cheap, requires user action. + - **Pubkey in recovery phrase / account export.** Derive or include the + pubkey fingerprint in something the user already verifies. + - **Web-of-trust / TOFU.** First-seen pubkey is pinned client-side; a change + triggers a user-visible warning. + These are additive — none changes the core envelope design, so they can + ship later without a migration. + +3. **Share record schema.** ✅ **Decided: `profile_shares` collection, schema + as proposed, permissions included.** Fields: + `{profileId, recipientUserId, ephemeralPubKey, wrappedDek, iv, permissions, + expiresAt, active, createdAt}`. `permissions` is a list (e.g. + `["read"]`, `["read","write"]`, `["read","write","admin"]`); the server + enforces write/admin server-side by checking the share record (the owner + always implicitly has admin via the owner share). See §"Permissions and + the admin-share question" below for the semantics of `admin`. + +4. **Migration.** ✅ **Decided: no migration — wipe the database.** There is + no real user data, and the re-key cost is unjustified. Phase A therefore + includes a clean-cutover step rather than a per-document migration. (This + also removes any legacy single-DEK documents that would otherwise need + special-casing.) + +5. **Existing `Share` model fate.** ✅ **Decided: remove entirely.** + `backend/src/models/share.rs`, the `/api/shares` routes, and the + `permissions/check` endpoint are deleted; the new `profile_shares` + collection and `/api/profiles/:id/shares` endpoints replace them. The + future per-resource physician-share feature will be built from scratch on + its own model when prioritized (it's a different access pattern — one-way + document dump, not ongoing profile access). + +## Phasing (proposal) + +- **Phase A — key tiers without sharing.** Introduce per-profile DEKs and the + account X25519 keypair; migrate existing data; no sharing endpoints yet. + Unblocks multi-profile-within-account (the parent/child single-login case). +- **Phase B — profile sharing.** The `POST/GET/DELETE /profiles/:id/shares` + endpoints and soft revoke. +- **Phase C — hard revoke / re-key.** The `/profiles/:id/rekey` endpoint and + owner-driven DEK rotation. +- **Phase D — graduation / ownership transfer.** Owner-share swap UI + endpoint. + +Each phase is independently shippable and independently testable. + +--- + +## References + +- Issue: [#3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3) +- Predecessor ADR: [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md) +- Current client crypto: `web/normogen-web/src/crypto/keys.ts`, `cipher.ts` +- Profile model: `backend/src/models/profile.rs` +- (Incompatible) current share model: `backend/src/models/share.rs` +- Web Crypto API: X25519 via `generateKey({name:"ECDH", namedCurve:"X25519"})`, + `deriveBits` for ECDH, `wrapKey`/`unwrapKey` or manual AES-GCM for the envelope. diff --git a/docs/product/ENCRYPTION_UPDATE_SUMMARY.md b/docs/archive/ENCRYPTION_UPDATE_SUMMARY.md similarity index 85% rename from docs/product/ENCRYPTION_UPDATE_SUMMARY.md rename to docs/archive/ENCRYPTION_UPDATE_SUMMARY.md index 2d745ea..68df3f4 100644 --- a/docs/product/ENCRYPTION_UPDATE_SUMMARY.md +++ b/docs/archive/ENCRYPTION_UPDATE_SUMMARY.md @@ -1,5 +1,13 @@ # Encryption.md Update Summary +> **ARCHIVED 2026-07-18.** This was a changelog note for a 2026-03-09 rewrite of +> `docs/product/encryption.md` that itself became stale (it describes ZK +> encryption as "planned" when it is in fact implemented). Kept here for +> history only. The current, accurate encryption doc is +> [`docs/product/encryption.md`](../product/encryption.md), and the canonical +> design is [`docs/adr/zero-knowledge-encryption.md`](../adr/zero-knowledge-encryption.md). +> See issue #4. + **Date**: 2026-03-09 **File**: docs/product/encryption.md **Update**: Added Rust implementation examples and current status diff --git a/docs/product/PERSONA_AND_FAMILY_MANAGEMENT.md b/docs/product/PERSONA_AND_FAMILY_MANAGEMENT.md index 5a99c39..e1131f8 100644 --- a/docs/product/PERSONA_AND_FAMILY_MANAGEMENT.md +++ b/docs/product/PERSONA_AND_FAMILY_MANAGEMENT.md @@ -1,10 +1,16 @@ # Persona and Family Management - Product Definition -**Document Version**: 1.0 -**Date**: 2026-03-09 -**Status**: Draft - For Refinement +**Document Version**: 1.1 +**Date**: 2026-07-18 (reconciled with code; original draft 2026-03-09) +**Status**: Vision doc — implementation status section corrected to match code **Purpose**: Consolidate all current information about persona and family management features for further product refinement +> **How to read this doc.** Sections marked **Vision** describe the target +> product and are still valid for discussion. Sections marked **Today (code)** +> describe what is actually implemented and have been verified against the +> codebase as of 2026-07-18. Where the two diverge, the gap is called out and +> linked to the relevant Forgejo issue. + --- ## 📋 Table of Contents @@ -35,6 +41,28 @@ Normogen supports **multi-person health data management** through a persona and --- +## Current reality (as of 2026-07-18) + +This section corrects the earlier "Current Implementation Status" section below, +which described an earlier state. Verified against the code: + +| Capability | Status | Notes | +|---|---|---| +| Single profile per user account | ✅ Done | `GET/PUT /api/profiles/me`; profile is auto-created as `profile_{user_id}` at registration. Display name is client-encrypted. | +| Multi-profile (child/dependent) within one account | ❌ Not built | `ProfileRepository::find_by_user_id` returns a single profile; no create/list/switch endpoints. `profileId` can be tagged onto medications but there is no backing entity beyond the owner's. | +| Family groups | ❌ Not built | `Family` model exists but has **no handler and no routes**. JWT carries `family_id` but nothing populates or checks it. | +| Cross-user sharing (spouse, caregiver) | ❌ **Blocked by design** | `/api/shares` routes exist but cannot grant decryption access — all data is encrypted under the owner's DEK and there is no key-distribution mechanism. See **[issue #3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3)**. | +| Caregiver roles / permissions | ❌ Not built | `permissions` field exists on Profile and in JWT claims but is not enforced. | +| Zero-knowledge encryption of health data | ✅ Done | Client-side Web Crypto, wrapped-DEK recovery. See [`encryption.md`](encryption.md). | + +**Implication for the family/caregiver personas below:** the single biggest +open question is architectural — *how does sharing work under zero-knowledge?* +That must be decided before family/caregiver features can be implemented. Until +then, the realistic near-term scope is **multi-profile within a single account** +(one login managing several people's data, no cross-account sharing). + +--- + ## User Personas ### Primary: Privacy-Conscious Individual @@ -167,7 +195,12 @@ User Account: jane.doe@example.com (Jane) --- -## Current Implementation Status +## Current Implementation Status (superseded — see "Current reality" above) + +> The detail below was written 2026-03-09 against an earlier state and +> overstates what existed then. It is retained for history. For the accurate +> current picture, read the **Current reality** section near the top of this +> document, and [`encryption.md`](encryption.md) for encryption. ### ✅ Implemented (Backend) @@ -595,16 +628,23 @@ if profile.role == "child" || profile.age < 18 { ### Encryption -**Zero-Knowledge Encryption**: -- Profile names are encrypted (AES-256-GCM) -- Family names are encrypted -- Only users with correct password can decrypt -- Server cannot see profile names +> **Correction (2026-07-18):** the bullets below described a planned +> server-side scheme that was never built. The shipped system is +> **client-side** zero-knowledge via the browser's Web Crypto API with a +> wrapped-DEK model. The server has no crypto code at all — it is a blind +> store. Profile/medication/appointment data is encrypted in the browser +> before upload and the server cannot read any of it. Full details: +> [`encryption.md`](encryption.md) and +> [`adr/zero-knowledge-encryption.md`](../adr/zero-knowledge-encryption.md). -**Encrypted Fields**: -- `profile.name` - Person's name -- `family.name` - Family group name -- Medication data (when implemented) +**What's actually encrypted (client-side, today):** +- Medication data blob (name, dosage, frequency, route, notes, …) +- Appointment data blob (title, provider, date/time, …) +- Profile display name + +**What stays plaintext (queryable by the server):** +- IDs (`profileId`, `medicationId`, `userId`, `appointmentId`) +- Medication `active` flag, appointment `status`, `doseSchedule`, timestamps --- diff --git a/docs/product/encryption.md b/docs/product/encryption.md index 55302f6..a7e30ab 100644 --- a/docs/product/encryption.md +++ b/docs/product/encryption.md @@ -1,906 +1,257 @@ -# Zero-Knowledge Encryption Implementation Guide +# Zero-Knowledge Encryption -## Table of Contents -1. [Proton-Style Encryption for MongoDB](#proton-style-encryption-for-mongodb) -2. [Shareable Links with Embedded Passwords](#shareable-links-with-embedded-passwords) -3. [Security Best Practices](#security-best-practices) -4. [Advanced Features](#advanced-features) -5. [Rust Implementation Examples](#rust-implementation-examples) 🆕 +**Status**: Implemented (Phase 1 + Phase 2) +**Last updated**: 2026-07-18 +**Canonical ADR**: [`docs/adr/zero-knowledge-encryption.md`](../adr/zero-knowledge-encryption.md) + +> The ADR is the source of truth for the design and its rationale. This document +> is the user-facing/product description of the same system, kept in sync with +> the code in `web/normogen-web/src/crypto/` and `backend/src/`. --- -## 🚨 Implementation Status +## 1. The core property -**Last Updated**: 2026-03-09 +Normogen is a **blind store**. The server holds opaque encrypted blobs and can +never read user health data. This is not a future goal — it is how the system +works today: -### Currently Implemented in Normogen ✅ -- ✅ JWT authentication (15min access tokens, 30day refresh tokens) -- ✅ PBKDF2 password hashing (100,000 iterations) -- ✅ Password recovery with zero-knowledge phrases -- ✅ Rate limiting (tower-governor) -- ✅ Account lockout policies -- ✅ Security audit logging -- ✅ Session management +- All sensitive user data (medications, appointments, profile display names) is + **encrypted in the browser** before it leaves the client, using the Web Crypto + API. +- The encryption key is **derived from the user's password** in the browser and + **never transmitted** to the server. +- The backend has **no crypto dependencies** (`Cargo.toml` has no `aes`, `gcm`, + or similar crates). It stores and returns ciphertext verbatim. -### Not Yet Implemented 📋 -- 📋 End-to-end encryption for health data -- 📋 Client-side encryption before storage -- 📋 Zero-knowledge encryption implementation -- 📋 Shareable links with embedded passwords - -> **Note**: The sections below provide a comprehensive guide for implementing zero-knowledge encryption. These are design documents for future implementation. +This is the feature that differentiates Normogen from commercial health +platforms: even the operator of the server cannot read your data. --- -## Proton-Style Encryption for MongoDB +## 2. Key model (wrapped-DEK) -### Architecture Overview +Two layers of keys: ``` -Application Layer (Client Side) -├── Encryption/Decryption happens HERE -├── Queries constructed with encrypted searchable fields -└── Data never leaves application unencrypted - -MongoDB (Server Side) -└── Stores only encrypted data + ┌─── password ───┐ + │ │ + PBKDF2(150k, SHA-256) │ │ PBKDF2(150k, SHA-256) + salt: normogen-auth-v1│ │ salt: normogen-kek-password-v1 + ▼ ▼ + auth secret password KEK + (base64) (AES-GCM key, in-memory) + │ │ + │ │ wraps/unwraps + │ ▼ + │ ┌─── random DEK ────┐ + │ │ AES-256-GCM key │ encrypts/decrypts + │ │ (in-memory only) │ all user data + │ └────────────────────┘ + │ ▲ + │ │ also wrapped under + │ │ recovery KEK (PBKDF2 of + │ │ recovery phrase) + ▼ + sent to server both wrapped forms stored + as "the password" on the server (opaque blobs) ``` -### Implementation Approaches +- **DEK (Data Encryption Key)** — a random 256-bit AES-GCM key generated at + registration. It directly encrypts all user data. Lives **only in browser + memory** for the session. +- **KEK (Key Encryption Key)** — derived from the password (or recovery phrase) + via PBKDF2. Used only to wrap (encrypt) the DEK so it can be stored on the + server. +- The server stores **two wrapped forms of the DEK**: + - `password_wrapped_dek` — DEK encrypted under `KEK(password)` + - `recovery_wrapped_dek` — DEK encrypted under `KEK(recovery phrase)` +- The server never holds the DEK itself, nor either KEK — only the wrapped + forms, which it cannot decrypt. -#### 1. Application-Level Encryption (Recommended) +### Key derivation parameters -Encrypt sensitive fields before they reach MongoDB. +| Purpose | KDF | Salt (domain separator) | Iterations | +|---|---|---|---| +| Auth secret (sent to server) | PBKDF2-HMAC-SHA-256 | `normogen-auth-v1` | 150,000 | +| Password KEK | PBKDF2-HMAC-SHA-256 | `normogen-kek-password-v1` | 150,000 | +| Recovery KEK | PBKDF2-HMAC-SHA-256 | `normogen-kek-recovery-v1` | 150,000 | + +Domain-separated salts mean the same password produces three independent, +non-reusable values. + +> Reference: `web/normogen-web/src/crypto/keys.ts`. --- -## Rust Implementation Examples 🆕 +## 3. Lifecycle -### Current Security Implementation +### Registration -Normogen currently implements the following security features in Rust: +1. Client runs `setupEncryption(password, recoveryPhrase?)`: + - derives `authSecret = PBKDF2(password, auth-salt)` + - generates a random DEK + - derives `passwordKEK = PBKDF2(password, kek-password-salt)` + - wraps the DEK under the password KEK → `passwordWrappedDek` + - (optional) wraps the DEK under the recovery KEK → `recoveryWrappedDek` +2. Client sends `{ email, password: authSecret, wrapped_dek, recovery_wrapped_dek?, recovery_phrase_hash? }` to `POST /api/auth/register`. +3. Server PBKDF2-hashes the auth secret (as it would any password) and stores + the wrapped-DEK blobs verbatim. A default `profile_{user_id}` profile is + created. -#### 1. JWT Authentication Service +### Login (unlock) -**File**: `backend/src/auth/mod.rs` +1. Client derives `authSecret = PBKDF2(password, auth-salt)` and sends it to + `POST /api/auth/login`. +2. Server verifies the auth secret against its stored hash and returns JWTs + **plus the user's `password_wrapped_dek`**. +3. Client derives `passwordKEK`, **unwraps** the DEK, and holds it in memory. + Until this succeeds the user is authenticated but cannot read/write any + encrypted data — this is the "unlock" step (`UnlockPage.tsx`). -```rust -use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; -use serde::{Deserialize, Serialize}; -use chrono::{Duration, Utc}; +### Password change -#[derive(Debug, Serialize, Deserialize)] -pub struct Claims { - pub sub: String, // User ID - pub exp: usize, // Expiration time - pub iat: usize, // Issued at - pub token_type: String, // "access" or "refresh" -} +1. Client derives the old password KEK, unwraps the DEK. +2. Client **re-wraps** the same DEK under the new password KEK (`rewrapDek`). +3. Client sends the new wrapped DEK to the server. The DEK itself does not + change, so existing data stays readable. +4. Server bumps `token_version`, invalidating all existing JWTs. -pub struct AuthService { - jwt_secret: String, -} +### Password recovery (via recovery phrase) -impl AuthService { - pub fn new(jwt_secret: String) -> Self { - Self { jwt_secret } - } +1. User enters email → `GET /api/auth/recovery-info` returns the + `recovery_wrapped_dek`. +2. User enters their recovery phrase → client derives the recovery KEK and + **unwraps the DEK** from the recovery-wrapped form. +3. User sets a new password → client **re-wraps** the DEK under the new + password KEK. +4. Client sends the new password-wrapped DEK to + `POST /api/auth/recover-password`. +5. Server increments `token_version` (all prior tokens invalidated). - /// Generate access token (15 minute expiry) - pub fn generate_access_token(&self, user_id: &str) -> Result { - let expiration = Utc::now() - .checked_add_signed(Duration::minutes(15)) - .expect("valid timestamp") - .timestamp() as usize; - - let claims = Claims { - sub: user_id.to_owned(), - exp: expiration, - iat: Utc::now().timestamp() as usize, - token_type: "access".to_string(), - }; - - encode( - &Header::default(), - &claims, - &EncodingKey::from_secret(self.jwt_secret.as_ref()), - ) - .map_err(|e| Error::TokenCreation(e.to_string())) - } - - /// Generate refresh token (30 day expiry) - pub fn generate_refresh_token(&self, user_id: &str) -> Result { - let expiration = Utc::now() - .checked_add_signed(Duration::days(30)) - .expect("valid timestamp") - .timestamp() as usize; - - let claims = Claims { - sub: user_id.to_owned(), - exp: expiration, - iat: Utc::now().timestamp() as usize, - token_type: "refresh".to_string(), - }; - - encode( - &Header::default(), - &claims, - &EncodingKey::from_secret(self.jwt_secret.as_ref()), - ) - .map_err(|e| Error::TokenCreation(e.to_string())) - } - - /// Validate JWT token - pub fn validate_token(&self, token: &str) -> Result { - decode::( - token, - &DecodingKey::from_secret(self.jwt_secret.as_ref()), - &Validation::default(), - ) - .map(|data| data.claims) - .map_err(|e| Error::TokenValidation(e.to_string())) - } -} -``` - -#### 2. Password Hashing with PBKDF2 - -**File**: `backend/src/auth/mod.rs` - -```rust -use pbkdf2::{ - password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, SaltString}, - Pbkdf2, - pbkdf2::Params, -}; - -pub struct PasswordService; - -impl PasswordService { - /// Hash password using PBKDF2 (100,000 iterations) - pub fn hash_password(password: &str) -> Result { - let params = Params::new(100_000, 0, 32, 32).expect("valid params"); - let salt = SaltString::generate(&mut OsRng); - - let password_hash = Pbkdf2 - .hash_password(password.as_bytes(), &salt) - .map_err(|e| Error::Hashing(e.to_string()))?; - - Ok(password_hash.to_string()) - } - - /// Verify password against hash - pub fn verify_password(password: &str, hash: &str) -> Result { - let parsed_hash = PasswordHash::new(hash) - .map_err(|e| Error::HashValidation(e.to_string()))?; - - Pbkdf2 - .verify_password(password.as_bytes(), &parsed_hash) - .map(|_| true) - .map_err(|e| match e { - pbkdf2::password_hash::Error::Password => Ok(false), - _ => Err(Error::HashValidation(e.to_string())), - })? - } - - /// Generate zero-knowledge recovery phrase - pub fn generate_recovery_phrase() -> String { - use rand::Rng; - const PHRASE_LENGTH: usize = 12; - const WORDS: &[&str] = &[ - "alpha", "bravo", "charlie", "delta", "echo", "foxtrot", - "golf", "hotel", "india", "juliet", "kilo", "lima", - "mike", "november", "oscar", "papa", "quebec", "romeo", - "sierra", "tango", "uniform", "victor", "whiskey", "xray", - "yankee", "zulu", - ]; - - let mut rng = rand::thread_rng(); - let phrase: Vec = (0..PHRASE_LENGTH) - .map(|_| WORDS[rng.gen_range(0..WORDS.len())].to_string()) - .collect(); - - phrase.join("-") - } -} -``` - -#### 3. Rate Limiting Middleware - -**File**: `backend/src/middleware/mod.rs` - -```rust -use axum::{ - extract::Request, - http::StatusCode, - middleware::Next, - response::Response, -}; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::RwLock; -use tower_governor::{ - governor::GovernorConfigBuilder, - key_bearer::BearerKeyExtractor, -}; - -#[derive(Clone)] -pub struct RateLimiter { - config: Arc>, -} - -impl RateLimiter { - pub fn new() -> Self { - let config = GovernorConfigBuilder::default() - .per_second(15) // 15 requests per second - .burst_size(30) // Allow bursts of 30 requests - .finish() - .unwrap(); - - Self { - config: Arc::new(config), - } - } -} - -/// Rate limiting middleware for Axum -pub async fn rate_limit_middleware( - req: Request, - next: Next, -) -> Result { - // Extract user ID or IP address for rate limiting - let key = extract_key(&req)?; - - // Check rate limit - // Implementation depends on tower-governor setup - - Ok(next.run(req).await) -} - -fn extract_key(req: &Request) -> Result { - // Extract from JWT token or IP address - // For now, use IP address - Ok("client_ip".to_string()) -} -``` - -#### 4. Account Lockout Service - -**File**: `backend/src/security/account_lockout.rs` - -```rust -use std::collections::HashMap; -use std::sync::Arc; -use tokio::sync::RwLock; -use std::time::{Duration, Instant}; - -#[derive(Clone)] -pub struct FailedLoginAttempt { - pub count: u32, - pub first_attempt: Instant, - pub last_attempt: Instant, -} - -pub struct AccountLockoutService { - attempts: Arc>>, - max_attempts: u32, - base_lockout_duration: Duration, - max_lockout_duration: Duration, -} - -impl AccountLockoutService { - pub fn new() -> Self { - Self { - attempts: Arc::new(RwLock::new(HashMap::new())), - max_attempts: 5, - base_lockout_duration: Duration::from_secs(900), // 15 minutes - max_lockout_duration: Duration::from_secs(86400), // 24 hours - } - } - - /// Record failed login attempt - pub async fn record_failed_attempt(&self, user_id: &str) -> Duration { - let mut attempts = self.attempts.write().await; - let now = Instant::now(); - - let attempt = attempts.entry(user_id.to_string()).or_insert_with(|| { - FailedLoginAttempt { - count: 0, - first_attempt: now, - last_attempt: now, - } - }); - - attempt.count += 1; - attempt.last_attempt = now; - - // Calculate lockout duration (exponential backoff) - if attempt.count >= self.max_attempts { - let lockout_duration = self.calculate_lockout_duration(attempt.count); - return lockout_duration; - } - - Duration::ZERO - } - - /// Clear failed login attempts on successful login - pub async fn clear_attempts(&self, user_id: &str) { - let mut attempts = self.attempts.write().await; - attempts.remove(user_id); - } - - /// Check if account is locked - pub async fn is_locked(&self, user_id: &str) -> bool { - let attempts = self.attempts.read().await; - if let Some(attempt) = attempts.get(user_id) { - if attempt.count >= self.max_attempts { - let lockout_duration = self.calculate_lockout_duration(attempt.count); - return attempt.last_attempt.add_duration(lockout_duration) > Instant::now(); - } - } - false - } - - /// Calculate lockout duration with exponential backoff - fn calculate_lockout_duration(&self, attempt_count: u32) -> Duration { - let base_secs = self.base_lockout_duration.as_secs() as u32; - let exponent = attempt_count.saturating_sub(self.max_attempts); - - // Exponential backoff: 15min, 30min, 1hr, 2hr, 4hr, max 24hr - let duration_secs = base_secs * 2_u32.pow(exponent.min(4)); - - let duration = Duration::from_secs(duration_secs as u64); - duration.min(self.max_lockout_duration) - } -} -``` - -#### 5. Security Audit Logger - -**File**: `backend/src/security/audit_logger.rs` - -```rust -use chrono::Utc; -use serde::{Deserialize, Serialize}; -use mongodb::{ - bson::{doc, Bson}, - Collection, Database, -}; - -#[derive(Debug, Serialize, Deserialize)] -pub struct AuditLog { - #[serde(rename = "_id")] - pub id: String, - pub user_id: String, - pub action: String, - pub resource: String, - pub details: serde_json::Value, - pub ip_address: String, - pub user_agent: String, - pub timestamp: i64, - pub success: bool, -} - -pub struct AuditLogger { - collection: Collection, -} - -impl AuditLogger { - pub fn new(db: &Database) -> Self { - Self { - collection: db.collection("audit_logs"), - } - } - - /// Log security event - pub async fn log_event( - &self, - user_id: &str, - action: &str, - resource: &str, - details: serde_json::Value, - ip_address: &str, - user_agent: &str, - success: bool, - ) -> Result<(), Error> { - let log_entry = AuditLog { - id: uuid::Uuid::new_v4().to_string(), - user_id: user_id.to_string(), - action: action.to_string(), - resource: resource.to_string(), - details, - ip_address: ip_address.to_string(), - user_agent: user_agent.to_string(), - timestamp: Utc::now().timestamp_millis(), - success, - }; - - self.collection - .insert_one(log_entry) - .await - .map_err(|e| Error::Database(e.to_string()))?; - - Ok(()) - } - - /// Log authentication attempt - pub async fn log_auth_attempt( - &self, - user_id: &str, - method: &str, // "login", "register", "logout" - success: bool, - ip_address: &str, - user_agent: &str, - ) -> Result<(), Error> { - let details = serde_json::json!({ - "method": method, - "success": success, - }); - - self.log_event( - user_id, - "authentication", - "auth", - details, - ip_address, - user_agent, - success, - ) - .await - } - - /// Log authorization attempt - pub async fn log_permission_check( - &self, - user_id: &str, - resource: &str, - permission: &str, - success: bool, - ip_address: &str, - ) -> Result<(), Error> { - let details = serde_json::json!({ - "permission": permission, - "success": success, - }); - - self.log_event( - user_id, - "authorization", - resource, - details, - ip_address, - "system", - success, - ) - .await - } -} -``` +This is the hard problem in zero-knowledge systems and it is solved here +without the server ever touching an unwrapped key. --- -## Future Zero-Knowledge Encryption Design +## 4. What is encrypted vs. plaintext -### Proposed Implementation for Health Data +The split is deliberate: enough stays plaintext for the server to query and +aggregate (filtering, adherence math); everything sensitive is ciphertext. -The following sections describe how to implement zero-knowledge encryption for sensitive health data. This is currently **not implemented** in Normogen. +### Encrypted (client-side, under the DEK) -#### 1. Encryption Service Design +- Medication data blob — name, dosage, frequency, route, notes, etc. (the whole + record packed into one `encrypted_data` blob) +- Appointment data blob — title, provider, date/time, etc. +- Profile display name -```rust -use aes_gcm::{ - aead::{Aead, AeadCore, KeyInit, OsRng}, - Aes256Gcm, Nonce, -}; -use rand::RngCore; +### Plaintext (server-visible, queryable) -pub struct EncryptionService { - cipher: Aes256Gcm, -} +- IDs: `medicationId`, `appointmentId`, `userId`, `profileId` +- Medication `active` flag (for `GET /api/medications?active=true`) +- Appointment `status` +- `doseSchedule` (so the server can compute adherence from logged doses without + decrypting the medication record) +- Timestamps -impl EncryptionService { - /// Create new encryption service with key - pub fn new(key: &[u8; 32]) -> Self { - let cipher = Aes256Gcm::new(key.into()); - Self { cipher } - } +### Wire shape - /// Encrypt data - pub fn encrypt(&self, plaintext: &[u8]) -> Result, Error> { - let nonce = Aes256Gcm::generate_nonce(&mut OsRng); - let ciphertext = self.cipher.encrypt(&nonce, plaintext) - .map_err(|e| Error::Encryption(e.to_string()))?; - - // Return nonce + ciphertext - let mut result = nonce.to_vec(); - result.extend_from_slice(&ciphertext); - Ok(result) - } - - /// Decrypt data - pub fn decrypt(&self, data: &[u8]) -> Result, Error> { - if data.len() < 12 { - return Err(Error::Decryption("Invalid data length".to_string())); - } - - let (nonce, ciphertext) = data.split_at(12); - let nonce = Nonce::from_slice(nonce); - - self.cipher.decrypt(nonce, ciphertext) - .map_err(|e| Error::Decryption(e.to_string())) - } - - /// Derive key from password using PBKDF2 - pub fn derive_key_from_password( - password: &str, - salt: &[u8; 32], - ) -> [u8; 32] { - use pbkdf2::pbkdf2_hmac; - use sha2::Sha256; - - let mut key = [0u8; 32]; - pbkdf2_hmac::( - password.as_bytes(), - salt, - 100_000, // iterations - &mut key, - ); - key - } -} +``` +EncryptedField { data: base64, iv: base64, auth_tag: base64 } ``` -#### 2. Encrypted Health Data Model +For AES-256-GCM via Web Crypto the 12-byte IV is generated randomly per +encryption; the auth tag is appended to the ciphertext by `subtle.encrypt` and +validated on decrypt. -```rust -use serde::{Deserialize, Serialize}; -use mongodb::bson::Uuid; - -#[derive(Debug, Serialize, Deserialize)] -pub struct EncryptedHealthData { - pub id: Uuid, - pub user_id: Uuid, - pub data_type: String, // "medication", "lab_result", "stat" - - // Encrypted fields - pub encrypted_data: Vec, - pub nonce: Vec, // 12 bytes for AES-256-GCM - - // Searchable (deterministically encrypted) - pub encrypted_name_searchable: Vec, - - pub created_at: i64, - pub updated_at: i64, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct HealthData { - pub id: Uuid, - pub user_id: Uuid, - pub data_type: String, - pub name: String, - pub value: serde_json::Value, - pub created_at: i64, - pub updated_at: i64, -} -``` - -#### 3. Deterministic Encryption for Searchable Fields - -```rust -use aes_gcm::{ - aead::{Aead, KeyInit}, - Aes256Gcm, Nonce, -}; - -pub struct DeterministicEncryption { - cipher: Aes256Gcm, -} - -impl DeterministicEncryption { - /// Create deterministic encryption from key - /// Note: Uses same nonce for same input (less secure, but searchable) - pub fn new(key: &[u8; 32]) -> Self { - let cipher = Aes256Gcm::new(key.into()); - Self { cipher } - } - - /// Generate nonce from input data (deterministic) - fn generate_nonce_from_data(data: &[u8]) -> [u8; 12] { - use sha2::{Sha256, Digest}; - let hash = Sha256::digest(data); - let mut nonce = [0u8; 12]; - nonce.copy_from_slice(&hash[..12]); - nonce - } - - /// Encrypt deterministically (same input = same output) - pub fn encrypt(&self, data: &[u8]) -> Result, Error> { - let nonce_bytes = Self::generate_nonce_from_data(data); - let nonce = Nonce::from_slice(&nonce_bytes); - - self.cipher.encrypt(nonce, data) - .map_err(|e| Error::Encryption(e.to_string())) - } -} -``` +> Reference: `web/normogen-web/src/crypto/cipher.ts`, `backend/src/models/medication.rs` (`EncryptedFieldWire`). --- -## Key Management Strategy +## 5. In-memory key lifecycle (the ZK trade-off) -### Environment Variables +The DEK lives only in browser memory for the authenticated session. It is +**not persisted** to `localStorage`, `sessionStorage`, or a cookie. -```bash -# backend/.env -JWT_SECRET=your-256-bit-secret-key-here -MASTER_ENCRYPTION_KEY=your-256-bit-encryption-key-here -SHARE_LINK_MASTER_KEY=your-256-bit-share-key-here -``` +Consequence: **a page reload requires re-entering the password** to re-derive +the key (the "unlock" screen). This is intentional. Convenience features that +would weaken this (e.g. "remember me" that persists the DEK) are out of scope +unless separately designed. -### Key Derivation - -```rust -use sha2::{Sha256, Digest}; - -pub struct KeyManager { - master_key: [u8; 32], -} - -impl KeyManager { - pub fn new(master_key: [u8; 32]) -> Self { - Self { master_key } - } - - /// Derive unique key per user - pub fn derive_user_key(&self, user_id: &str) -> [u8; 32] { - let mut hasher = Sha256::new(); - hasher.update(format!("{}:{}", hex::encode(self.master_key), user_id)); - hasher.finalize().into() - } - - /// Derive key for specific document - pub fn derive_document_key(&self, user_id: &str, doc_id: &str) -> [u8; 32] { - let mut hasher = Sha256::new(); - hasher.update(format!( - "{}:{}:{}", - hex::encode(self.master_key), - user_id, - doc_id - )); - hasher.finalize().into() - } -} -``` +On logout / tab close, the key is cleared via `clearEncKey()`. --- -## Shareable Links with Embedded Passwords +## 6. What is NOT yet solved (see issues) -### Architecture Overview +### Multi-person / family / caregiver sharing — unsolved -``` -1. User has encrypted data in MongoDB -2. Creates a public share with a password -3. Generates a shareable link with encrypted password -4. External user clicks link → password extracted → data decrypted -``` +Sharing a medication with a spouse or caregiver requires the **recipient** to +be able to decrypt it, but everything is encrypted under the **owner's** DEK. +There is currently no key-distribution mechanism (no asymmetric envelope, no +re-wrapping to a recipient key). The `Share` model and `/api/shares` routes +exist but cannot actually grant decryption access to another user. -### Implementation Design +This is a design problem, not a bug, and it blocks the family/caregiver +personas. See **[issue #3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3)**. -```rust -use rand::RngCore; +### Multi-profile within one account — partial -#[derive(Debug, Serialize, Deserialize)] -pub struct ShareableLink { - pub share_id: String, - pub encrypted_password: String, // Embedded in URL - pub expires_at: Option, - pub max_access_count: Option, -} +`profileId` is plaintext and queryable, so medications can be tagged to +different people under one account. But the only profile endpoint is +`GET/PUT /api/profiles/me` (single profile per user); there is no +create-child / list-profiles / switch flow yet. See +[`PERSONA_AND_FAMILY_MANAGEMENT.md`](PERSONA_AND_FAMILY_MANAGEMENT.md) for the +gap between vision and current implementation. -pub struct ShareService { - encryption_service: EncryptionService, -} +### Key rotation / re-encryption -impl ShareService { - /// Create shareable link - pub fn create_shareable_link( - &self, - data: &[u8], - expires_in_hours: Option, - max_access: Option, - ) -> Result { - // Generate random password - let mut password = [0u8; 32]; - OsRng.fill_bytes(&mut password); - - // Encrypt data with password - let encrypted_data = self.encryption_service.encrypt(&password, data)?; - - // Generate share ID - let share_id = generate_share_id(); - - // Encrypt password for URL embedding - let encrypted_password = self.encrypt_password_for_url(&password)?; - - Ok(ShareableLink { - share_id, - encrypted_password, - expires_at: expires_in_hours.map(|h| { - Utc::now().timestamp() + (h * 3600) as i64 - }), - max_access_count: max_access, - }) - } - - /// Encrypt password for embedding in URL - fn encrypt_password_for_url(&self, password: &[u8; 32]) -> Result { - // Use master key to encrypt password - let encrypted = self.encryption_service.encrypt( - &MASTER_ENCRYPTION_KEY, - password, - )?; - Ok(base64::encode(encrypted)) - } -} - -fn generate_share_id() -> String { - use rand::Rng; - const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - let mut rng = rand::thread_rng(); - - (0..16) - .map(|_| { - let idx = rng.gen_range(0..CHARSET.len()); - CHARSET[idx] as char - }) - .collect() -} -``` +There is no facility to rotate the DEK (re-encrypt all data under a new key) +or to migrate encryption parameters. Low priority until there's a reason. --- -## Password Recovery in Zero-Knowledge Systems +## 7. Backend security (complementary, server-side) -### Recovery with Zero-Knowledge Phrases +These are the server-side defenses that sit alongside (not instead of) the +client-side ZK encryption. They protect the account and the ciphertext store, +not the plaintext (which the server never has). -Currently implemented in Normogen: +| Feature | Implementation | +|---|---| +| Password hashing | PBKDF2 on the auth secret, salted | +| JWT access tokens | 15-minute expiry, claims `{sub, email, family_id, permissions, token_type, iat, exp, jti}` | +| JWT refresh tokens | 30-day expiry, stored in MongoDB, rotated on every refresh | +| Token revocation | `token_version` claim — bumped on password change/recovery, invalidates all outstanding tokens | +| Rate limiting | IP-based via `tower-governor` | +| Account lockout | 5 failed attempts → exponential backoff (15 min → 24 h max) | +| Session management | List / revoke sessions, revoke-all | +| Audit logging | Auth attempts, authorization checks, security events | -```rust -// Already implemented in backend/src/auth/mod.rs -impl AuthService { - pub fn generate_recovery_phrase() -> String { - // Returns phrase like "alpha-bravo-charlie-..." - } - - pub fn verify_recovery_phrase( - &self, - user_id: &str, - phrase: &str, - ) -> Result { - // Verify phrase and allow password reset - } -} -``` +> Reference: `backend/src/auth/`, `backend/src/security/`, `backend/src/models/audit_log.rs`. --- -## Security Best Practices +## 8. Threat model summary -### Current Implementation ✅ - -1. **Password Security** - - ✅ PBKDF2 with 100,000 iterations - - ✅ Random salt per password - - ✅ Passwords never logged - -2. **JWT Security** - - ✅ Short-lived access tokens (15 minutes) - - ✅ Long-lived refresh tokens (30 days) - - ✅ Token rotation on refresh - -3. **Rate Limiting** - - ✅ 15 requests per second - - ✅ Burst allowance of 30 requests - -4. **Account Lockout** - - ✅ 5 failed attempts trigger lockout - - ✅ Exponential backoff (15min → 24hr max) - -5. **Audit Logging** - - ✅ All security events logged - - ✅ IP address and user agent tracked - -### Recommendations for Future Enhancement - -1. **End-to-End Encryption** - - Implement client-side encryption - - Zero-knowledge encryption for health data - - Deterministic encryption for searchable fields - -2. **Key Rotation** - - Implement periodic key rotation - - Support for encryption key updates - -3. **Hardware Security Modules (HSM)** - - Consider HSM for production deployments - - Secure key storage and management - -4. **Compliance** - - HIPAA compliance measures - - GDPR compliance features - - Data localization options +| Threat | Mitigation | +|---|---| +| Server operator reads user data | Impossible — server only has ciphertext + wrapped keys; no DEK or KEK ever leaves the client | +| Database compromise | Ciphertext-only; data unreadable without a KEK, which requires the user's password | +| Stolen refresh token | Rotated on use; revocable via session list; `token_version` bump kills all tokens | +| Forgotten password | Recoverable via the recovery phrase (re-derives recovery KEK, unwraps DEK) | +| Lost recovery phrase **and** password | **Data is lost.** No escrow; by design. | +| Cross-user sharing | **Not yet supported** — see issue #3 | +| Replay/tamper of ciphertext | AES-GCM authenticated encryption detects tampering and wrong keys | --- -## Comparison: Current vs Proposed +## References -### Current Implementation ✅ - -| Feature | Status | Notes | -|---------|--------|-------| -| JWT Authentication | ✅ Implemented | 15min access, 30day refresh | -| Password Hashing | ✅ Implemented | PBKDF2, 100K iterations | -| Rate Limiting | ✅ Implemented | 15 req/s, burst 30 | -| Account Lockout | ✅ Implemented | 5 attempts, 15min-24hr | -| Audit Logging | ✅ Implemented | All security events | -| Session Management | ✅ Implemented | List, revoke sessions | -| Zero-Knowledge Encryption | 📋 Planned | Not yet implemented | - -### Proposed Implementation 📋 - -| Feature | Priority | Complexity | -|---------|----------|------------| -| Client-side Encryption | High | High | -| End-to-End Encryption | High | High | -| Deterministic Encryption | Medium | Medium | -| Shareable Links | Medium | Medium | -| Key Rotation | High | Medium | -| HSM Integration | Low | High | - ---- - -## Dependencies - -### Currently Used ✅ -```toml -# backend/Cargo.toml -jsonwebtoken = "9.3.1" # JWT authentication -pbkdf2 = "0.12" # Password hashing -rand = "0.8" # Random generation -sha2 = "0.10" # SHA-256 hashing -tower-governor = "0.4" # Rate limiting -chrono = "0.4" # Time handling -``` - -### To Add for Encryption 📋 -```toml -# Proposed additions -aes-gcm = "0.10" # AES-256-GCM encryption -base64 = "0.21" # Base64 encoding -uuid = "1.0" # UUID generation -``` - ---- - -## Summary - -This document provides: - -✅ **Current implementation**: JWT, PBKDF2, rate limiting, audit logging -✅ **Rust code examples**: Actual implementation from Normogen -✅ **Future design**: Zero-knowledge encryption architecture -✅ **Best practices**: Security recommendations and compliance - -### Implementation Status - -- **Security Features**: ✅ 85% complete -- **Encryption Features**: 📋 Planned for future phases -- **Documentation**: ✅ Complete - ---- - -**Last Updated**: 2026-03-09 -**Implementation Status**: Security features implemented, zero-knowledge encryption planned -**Next Review**: After Phase 2.8 completion +- ADR (canonical): [`docs/adr/zero-knowledge-encryption.md`](../adr/zero-knowledge-encryption.md) +- Client crypto: `web/normogen-web/src/crypto/` (`keys.ts`, `cipher.ts`, `index.ts`) +- Client flows: `RegisterPage.tsx`, `UnlockPage.tsx`, `RecoveryPage.tsx` +- Server wire shape: `backend/src/models/medication.rs` (`EncryptedFieldWire`), `backend/src/models/profile.rs`, `backend/src/models/appointment.rs` +- User key storage: `backend/src/models/user.rs` (`wrapped_dek`, `recovery_wrapped_dek`) +- Routes: `backend/src/app.rs` diff --git a/web/normogen-web/src/components/appointments/AppointmentsManager.tsx b/web/normogen-web/src/components/appointments/AppointmentsManager.tsx index d3ee7b0..ed6d8cf 100644 --- a/web/normogen-web/src/components/appointments/AppointmentsManager.tsx +++ b/web/normogen-web/src/components/appointments/AppointmentsManager.tsx @@ -24,7 +24,7 @@ import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import AddIcon from '@mui/icons-material/Add'; import { format } from 'date-fns'; -import { useAppointmentStore, useAuthStore } from '../../store/useStore'; +import { useAppointmentStore, useProfileStore } from '../../store/useStore'; import type { Appointment } from '../../types/api'; const APPT_TYPES = ['in-person', 'telehealth', 'lab', 'test', 'other']; @@ -74,7 +74,7 @@ export const AppointmentsManager: FC = () => { deleteAppointment, clearError, } = useAppointmentStore(); - const user = useAuthStore((s) => s.user); + const activeProfileId = useProfileStore((s) => s.activeProfileId); const [createOpen, setCreateOpen] = useState(false); const [createForm, setCreateForm] = useState(emptyCreate); @@ -84,13 +84,13 @@ export const AppointmentsManager: FC = () => { const [saving, setSaving] = useState(false); useEffect(() => { - loadAppointments(); - }, [loadAppointments]); + if (activeProfileId) loadAppointments(); + }, [loadAppointments, activeProfileId]); const openCreate = () => { setCreateForm({ ...emptyCreate, - profile_id: `profile_${user?.user_id ?? 'default'}`, + profile_id: activeProfileId ?? 'default', }); setCreateOpen(true); }; diff --git a/web/normogen-web/src/components/medication/MedicationManager.test.tsx b/web/normogen-web/src/components/medication/MedicationManager.test.tsx index b70c813..93c5d11 100644 --- a/web/normogen-web/src/components/medication/MedicationManager.test.tsx +++ b/web/normogen-web/src/components/medication/MedicationManager.test.tsx @@ -38,6 +38,8 @@ describe('MedicationManager', () => { resetMockStore(); setMockStore({ useAuthStore: { user: { user_id: 'u1', username: 'tester' }, profile: {} }, + // The load effect is gated on activeProfileId (Phase A2); provide one. + useProfileStore: { activeProfileId: 'profile_u1' }, }); baseActions.loadMedications.mockClear(); }); diff --git a/web/normogen-web/src/components/medication/MedicationManager.tsx b/web/normogen-web/src/components/medication/MedicationManager.tsx index e80679c..fcdfc09 100644 --- a/web/normogen-web/src/components/medication/MedicationManager.tsx +++ b/web/normogen-web/src/components/medication/MedicationManager.tsx @@ -24,7 +24,7 @@ import { import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import AddIcon from '@mui/icons-material/Add'; -import { useMedicationStore, useAuthStore } from '../../store/useStore'; +import { useMedicationStore, useProfileStore } from '../../store/useStore'; import type { Medication } from '../../types/api'; import { DoseLogger } from './DoseLogger'; @@ -60,7 +60,7 @@ export const MedicationManager: FC = () => { deleteMedication, clearError, } = useMedicationStore(); - const user = useAuthStore((s) => s.user); + const activeProfileId = useProfileStore((s) => s.activeProfileId); const [createOpen, setCreateOpen] = useState(false); const [createForm, setCreateForm] = useState(emptyCreate); @@ -69,17 +69,16 @@ export const MedicationManager: FC = () => { const [deleteTarget, setDeleteTarget] = useState(null); const [saving, setSaving] = useState(false); + // Reload medications when the active profile changes — each profile's data + // is encrypted under its own DEK and filtered server-side by profile_id. useEffect(() => { - loadMedications(); - }, [loadMedications]); + if (activeProfileId) loadMedications(); + }, [loadMedications, activeProfileId]); const openCreate = () => { - // profile_id is deterministic: profile_. The backend auto-creates - // this profile on register, so the id always resolves to a real profile. - const profileId = user?.profile_id ?? `profile_${user?.user_id ?? 'default'}`; setCreateForm({ ...emptyCreate, - profile_id: profileId, + profile_id: activeProfileId ?? 'default', }); setCreateOpen(true); }; diff --git a/web/normogen-web/src/components/profile/ProfileEditor.tsx b/web/normogen-web/src/components/profile/ProfileEditor.tsx index 7f258f5..d070990 100644 --- a/web/normogen-web/src/components/profile/ProfileEditor.tsx +++ b/web/normogen-web/src/components/profile/ProfileEditor.tsx @@ -7,34 +7,62 @@ import { CircularProgress, Alert, Chip, + MenuItem, Stack, TextField, Typography, } from '@mui/material'; import SaveIcon from '@mui/icons-material/Save'; +import AddIcon from '@mui/icons-material/Add'; +import DeleteIcon from '@mui/icons-material/Delete'; import { useProfileStore, useAuthStore } from '../../store/useStore'; +import { ProfileSharing } from './ProfileSharing'; export const ProfileEditor: FC = () => { - const { profile, isLoading, error, loadProfile, updateName, clearError } = - useProfileStore(); + const { + profiles, + activeProfileId, + isLoading, + error, + loadProfiles, + updateProfile, + createProfile, + deleteProfile, + clearError, + } = useProfileStore(); const user = useAuthStore((s) => s.user); + const active = profiles.find((p) => p.profile_id === activeProfileId) ?? null; + const [name, setName] = useState(''); + const [kind, setKind] = useState('human'); + const [relationship, setRelationship] = useState(''); const [editing, setEditing] = useState(false); const [saving, setSaving] = useState(false); - useEffect(() => { - loadProfile(); - }, [loadProfile]); + // Create-profile dialog state + const [creating, setCreating] = useState(false); + const [newName, setNewName] = useState(''); + const [newKind, setNewKind] = useState('human'); + const [newRel, setNewRel] = useState(''); useEffect(() => { - if (profile?.name) setName(profile.name); - }, [profile]); + loadProfiles(); + }, [loadProfiles]); + + useEffect(() => { + if (active) { + setName(active.name); + setKind(active.kind || 'human'); + setRelationship(active.relationship || ''); + } + }, [active]); const handleSave = async () => { + if (!active) return; setSaving(true); try { - await updateName(name); + await updateProfile(active.profile_id, { name, kind, relationship }); setEditing(false); } catch { /* error surfaced via store */ @@ -43,11 +71,43 @@ export const ProfileEditor: FC = () => { } }; + const handleCreate = async () => { + setSaving(true); + try { + await createProfile({ name: newName, kind: newKind, relationship: newRel }); + setCreating(false); + setNewName(''); + setNewKind('human'); + setNewRel(''); + } catch { + /* error surfaced via store */ + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + if (!active) return; + if (profiles.length <= 1) { + alert('You must have at least one profile. Create another before deleting this one.'); + return; + } + if (!confirm(`Delete the "${active.name}" profile? Its encrypted data cannot be recovered.`)) return; + try { + await deleteProfile(active.profile_id); + } catch { + /* error surfaced via store */ + } + }; + return ( - - Profile - + + Profiles + + {error && ( @@ -55,11 +115,11 @@ export const ProfileEditor: FC = () => { )} - {isLoading && !profile ? ( + {isLoading && profiles.length === 0 ? ( - ) : ( + ) : active ? ( @@ -84,39 +144,154 @@ export const ProfileEditor: FC = () => { > {saving ? : 'Save'} - ) : ( - {profile?.name ?? user?.username ?? '—'} - + {active.name || user?.username || '—'} + {active.is_shared ? ( + + ) : ( + + )} )} - - - Account - - {user?.email} - - - - - Role - - - - - - - {profile?.profile_id && ( - - Profile ID: {profile.profile_id} - + {editing && ( + <> + + Kind + setKind(e.target.value)} + sx={{ mt: 0.5 }} + > + Human + Pet + + + + + Relationship + + setRelationship(e.target.value)} + placeholder="self, child, spouse, parent, pet, ..." + sx={{ mt: 0.5 }} + /> + + )} + + {!editing && ( + <> + + Kind / relationship + + + {active.relationship && ( + + )} + + + {active.is_shared ? ( + + Owner + {active.owner_account_id} + + You have read-only access to this shared profile. + + + ) : ( + <> + + Account + {user?.email} + + + Profile ID: {active.profile_id} + + + + + + )} + + )} + + {/* Owner-only sharing UI. */} + {!editing && !active.is_shared && ( + + + + )} + + + + ) : ( + + No profiles yet. Create one to start tracking health data. + + )} + + {creating && ( + + + New profile + + setNewName(e.target.value)} + /> + setNewKind(e.target.value)} + > + Human + Pet + + setNewRel(e.target.value)} + placeholder="child, spouse, parent, pet, ..." + /> + + + + diff --git a/web/normogen-web/src/components/profile/ProfileSharing.tsx b/web/normogen-web/src/components/profile/ProfileSharing.tsx new file mode 100644 index 0000000..54497dd --- /dev/null +++ b/web/normogen-web/src/components/profile/ProfileSharing.tsx @@ -0,0 +1,189 @@ +import { useEffect, useState, type FC } from 'react'; +import { + Box, + Button, + CircularProgress, + Alert, + IconButton, + List, + ListItem, + ListItemText, + ListItemSecondaryAction, + Stack, + TextField, + Typography, +} 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 + * profile is shared with (with revoke buttons) and an "add recipient by + * email" field. Shown only for profiles the current user owns. Recipients + * get the profile via /profiles/shared-with-me + the ECDH-wrapped DEK. */ +export const ProfileSharing: FC<{ profileId: string }> = ({ profileId }) => { + const { + profileShares, + loadProfileShares, + shareProfile, + revokeShare, + rekeyProfile, + error, + clearError, + } = useProfileStore(); + + const [recipientEmail, setRecipientEmail] = useState(''); + const [busy, setBusy] = useState(false); + const [localError, setLocalError] = useState(''); + + useEffect(() => { + loadProfileShares(profileId); + }, [profileId, loadProfileShares]); + + const shares = profileShares[profileId] ?? []; + + const handleAdd = async () => { + setBusy(true); + setLocalError(''); + try { + await shareProfile(profileId, recipientEmail.trim()); + setRecipientEmail(''); + } catch (e: any) { + setLocalError(e?.message || 'Failed to share'); + } finally { + setBusy(false); + } + }; + + const handleRevoke = async (recipientUserId: string) => { + if (!confirm('Revoke this share? The recipient will lose access immediately.')) return; + setBusy(true); + try { + await revokeShare(profileId, recipientUserId); + } catch { + /* surfaced via store error */ + } finally { + setBusy(false); + } + }; + + 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 ( + + + Shared with + + + {(error || localError) && ( + { + setLocalError(''); + clearError(); + }} + > + {localError || error} + + )} + + {shares.length === 0 ? ( + + Not shared with anyone. + + ) : ( + + {shares.map((s) => ( + + + + handleRevoke(s.recipient_user_id)} + > + + + + + ))} + + )} + + + setRecipientEmail(e.target.value)} + /> + + + + 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. + + + + ); +}; + +export default ProfileSharing; diff --git a/web/normogen-web/src/components/profile/ProfileSwitcher.tsx b/web/normogen-web/src/components/profile/ProfileSwitcher.tsx new file mode 100644 index 0000000..fa1c00a --- /dev/null +++ b/web/normogen-web/src/components/profile/ProfileSwitcher.tsx @@ -0,0 +1,66 @@ +import { useEffect, type FC } from 'react'; +import { Box, Chip, CircularProgress, MenuItem, Select, Typography } from '@mui/material'; +import { useProfileStore } from '../../store/useStore'; + +/** A compact dropdown for switching the active profile (the subject of care + * whose data the dashboard shows). Sits in the AppBar. Triggers `loadProfiles` + * + `loadSharedWithMe` on mount so owned AND shared profiles are ready. */ +export const ProfileSwitcher: FC = () => { + const { profiles, activeProfileId, isLoading, loadProfiles, loadSharedWithMe, setActiveProfile } = + useProfileStore(); + + useEffect(() => { + (async () => { + await loadProfiles(); + // Fetch profiles shared TO me after owned profiles are loaded (the + // identity private key must be unwrapped first; loadSharedWithMe is a + // no-op if it isn't available yet). + await loadSharedWithMe(); + })(); + }, [loadProfiles, loadSharedWithMe]); + + if (profiles.length === 0) { + return isLoading ? ( + + + + ) : ( + + No profile + + ); + } + + return ( + + ); +}; + +export default ProfileSwitcher; diff --git a/web/normogen-web/src/crypto/crypto.e2e.test.ts b/web/normogen-web/src/crypto/crypto.e2e.test.ts index b160193..4430b44 100644 --- a/web/normogen-web/src/crypto/crypto.e2e.test.ts +++ b/web/normogen-web/src/crypto/crypto.e2e.test.ts @@ -6,11 +6,43 @@ import { rewrapDek, encryptJson, decryptJson, + encrypt, + decrypt, setEncKey, clearEncKey, hasEncKey, + generateIdentityKeyPair, + wrapIdentityPrivateKey, + unwrapIdentityPrivateKey, + setIdentityPrivate, + clearIdentityPrivate, + hasIdentityPrivate, + generateProfileDek, + wrapProfileDek, + unwrapProfileDek, + wrapProfileDekToRecipient, + unwrapProfileDekFromShare, + setProfileDek, + getProfileDek, + clearProfileDeks, } from './index'; +/** X25519 in Web Crypto is a recent addition (Node 16+, modern browsers). Some + * test runtimes (older jsdom/Node) may not implement it; detect once and skip + * the identity-keypair tests rather than fail spuriously. */ +async function x25519Supported(): Promise { + try { + await crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'X25519' }, + true, + ['deriveBits'], + ); + return true; + } catch { + return false; + } +} + describe('zero-knowledge crypto lifecycle', () => { const password = 'my-secret-password'; const recoveryPhrase = 'my-recovery-phrase'; @@ -99,3 +131,205 @@ describe('zero-knowledge crypto lifecycle', () => { await expect(decryptJson(encrypted, setupB.dek)).rejects.toThrow(); }); }); + +describe('account X25519 identity keypair (Phase A1)', () => { + const password = 'my-secret-password'; + + it('full lifecycle: generate → wrap → unlock DEK → unwrap → deriveBits', async () => { + if (!(await x25519Supported())) { + console.log('[crypto] skipped (X25519 unsupported in this runtime)'); + return; + } + + // 1. Register: set up the account DEK and generate the identity keypair. + const setup = await setupEncryption(password); + const { publicKey, privateKey } = await generateIdentityKeyPair(); + expect(publicKey).not.toBe(''); + setIdentityPrivate(privateKey); + expect(hasIdentityPrivate()).toBe(true); + + // 2. Wrap the private key under the account DEK and "store" it. + const wrapped = await wrapIdentityPrivateKey(privateKey, setup.dek); + expect(wrapped.data).not.toBe(''); + + // 3. Simulate page reload: both keys are lost from memory. + clearIdentityPrivate(); + expect(hasIdentityPrivate()).toBe(false); + + // 4. Unlock the DEK with the password (unlock screen flow). + const { dek } = await unlockWithPassword(password, setup.passwordWrappedDek); + + // 5. Unwrap the identity private key from the stored wrapped form. + const recovered = await unwrapIdentityPrivateKey(wrapped, dek); + setIdentityPrivate(recovered); + expect(hasIdentityPrivate()).toBe(true); + + // 6. The recovered private key is a USABLE X25519 key: it can derive a + // shared secret against the original public key. Import the public key + // and ECDH-derive — this proves the round-trip preserved a working key. + const pubBytes = Uint8Array.from(atob(publicKey), (c) => c.charCodeAt(0)); + const pubKey = await crypto.subtle.importKey( + 'raw', + pubBytes as BufferSource, + { name: 'ECDH', namedCurve: 'X25519' }, + false, + [], + ); + const sharedSecret = await crypto.subtle.deriveBits( + { name: 'ECDH', public: pubKey }, + recovered, + 256, + ); + expect(sharedSecret.byteLength).toBe(32); + + clearIdentityPrivate(); + }); + + it('a wrapped private key cannot be unwrapped with the wrong DEK', async () => { + if (!(await x25519Supported())) { + console.log('[crypto] skipped (X25519 unsupported in this runtime)'); + return; + } + // DEK for account A; a different DEK for account B. + const setupA = await setupEncryption('user-a-password'); + const setupB = await setupEncryption('user-b-password'); + const { privateKey } = await generateIdentityKeyPair(); + const wrapped = await wrapIdentityPrivateKey(privateKey, setupA.dek); + + // User B's DEK cannot unwrap user A's identity private key. + await expect(unwrapIdentityPrivateKey(wrapped, setupB.dek)).rejects.toThrow(); + }); + + it('two identity keypairs derive different shared secrets with the same peer', async () => { + if (!(await x25519Supported())) { + console.log('[crypto] skipped (X25519 unsupported in this runtime)'); + return; + } + // Sanity: distinct keypairs must produce distinct ECDH outputs. + const peer = await generateIdentityKeyPair(); + const peerPubBytes = Uint8Array.from(atob(peer.publicKey), (c) => c.charCodeAt(0)); + const peerPub = await crypto.subtle.importKey( + 'raw', + peerPubBytes as BufferSource, + { name: 'ECDH', namedCurve: 'X25519' }, + false, + [], + ); + + const a = await generateIdentityKeyPair(); + const b = await generateIdentityKeyPair(); + const secretA = await crypto.subtle.deriveBits( + { name: 'ECDH', public: peerPub }, + a.privateKey, + 256, + ); + const secretB = await crypto.subtle.deriveBits( + { name: 'ECDH', public: peerPub }, + b.privateKey, + 256, + ); + + const aHex = Array.from(new Uint8Array(secretA)).map((x) => x.toString(16)).join(''); + const bHex = Array.from(new Uint8Array(secretB)).map((x) => x.toString(16)).join(''); + expect(aHex).not.toBe(bHex); + }); +}); + +describe('per-profile DEK isolation (Phase A2)', () => { + const password = 'my-secret-password'; + + it('two profiles get distinct DEKs, each wrapped under the account DEK', async () => { + // One account DEK (unlocked from the password). + const setup = await setupEncryption(password); + const accountDek = setup.dek; + + // Two profiles, each with its own random DEK. + const childDek = await generateProfileDek(); + const petDek = await generateProfileDek(); + + // Each profile DEK is wrapped under the account DEK for storage. + const childWrapped = await wrapProfileDek(childDek, accountDek); + const petWrapped = await wrapProfileDek(petDek, accountDek); + + // Both wrapped blobs decrypt cleanly under the account DEK. + const childUnwrapped = await unwrapProfileDek(childWrapped, accountDek); + const petUnwrapped = await unwrapProfileDek(petWrapped, accountDek); + expect(childUnwrapped).toBeDefined(); + expect(petUnwrapped).toBeDefined(); + + // Data encrypted under the child profile's DEK cannot be decrypted by the + // pet profile's DEK (and vice versa) — this is the per-profile isolation + // guarantee that makes the parent/child and pet cases safe. + const childData = await encrypt('child-medication', childDek); + await expect(decrypt(childData, petDek)).rejects.toThrow(); + const petData = await encrypt('pet-weight', petDek); + await expect(decrypt(petData, childDek)).rejects.toThrow(); + + // ...but each decrypts with its own key. + expect(await decrypt(childData, childDek)).toBe('child-medication'); + expect(await decrypt(petData, petDek)).toBe('pet-weight'); + }); + + it('the in-memory profile-DEK store maps profile ids to DEKs', async () => { + const childDek = await generateProfileDek(); + const petDek = await generateProfileDek(); + setProfileDek('profile_child', childDek); + setProfileDek('profile_pet', petDek); + // Each id resolves to the DEK that was stored under it; unknown ids resolve to null. + expect(getProfileDek('profile_child')).toBe(childDek); + expect(getProfileDek('profile_pet')).toBe(petDek); + expect(getProfileDek('profile_unknown')).toBeNull(); + // Clearing drops everything. + clearProfileDeks(); + expect(getProfileDek('profile_child')).toBeNull(); + }); +}); + +describe('profile sharing: X25519 envelope (Phase B)', () => { + it('owner wraps a profile DEK to a recipient, who unwraps it', async () => { + if (!(await x25519Supported())) { + console.log('[crypto] skipped (X25519 unsupported in this runtime)'); + return; + } + // Two accounts, each with their own identity keypair. + const owner = await generateIdentityKeyPair(); + const recipient = await generateIdentityKeyPair(); + + // A profile DEK the owner created (would normally be wrapped under the + // owner's account DEK too — irrelevant for this test). + const profileDek = await generateProfileDek(); + + // Owner wraps the profile DEK to the recipient's public key. Internally + // this derives a fresh ephemeral keypair + ECDH(recipient pub, ephemeral + // priv) and AES-GCM-wraps the DEK under the shared secret. + const envelope = await wrapProfileDekToRecipient( + profileDek, + recipient.publicKey, + owner.privateKey, // not actually used by wrap (only recipient pub matters) + ); + expect(envelope.ephemeralPublicKey).not.toBe(''); + expect(envelope.wrappedProfileDek.data).not.toBe(''); + + // Recipient unwraps with their private key + the envelope's ephemeral pub. + const recoveredDek = await unwrapProfileDekFromShare( + envelope.wrappedProfileDek, + envelope.ephemeralPublicKey, + recipient.privateKey, + ); + expect(recoveredDek).toBeDefined(); + + // The recovered DEK encrypts/decrypts the same data the original did. + const data = await encrypt('shared-medication', profileDek); + expect(await decrypt(data, recoveredDek)).toBe('shared-medication'); + + // A *different* recipient's private key cannot unwrap the share. + const stranger = await generateIdentityKeyPair(); + await expect( + unwrapProfileDekFromShare( + envelope.wrappedProfileDek, + envelope.ephemeralPublicKey, + stranger.privateKey, + ), + ).rejects.toThrow(); + }); +}); diff --git a/web/normogen-web/src/crypto/index.ts b/web/normogen-web/src/crypto/index.ts index 16ec69f..798cec2 100644 --- a/web/normogen-web/src/crypto/index.ts +++ b/web/normogen-web/src/crypto/index.ts @@ -8,10 +8,29 @@ export { setupEncryption, unlockWithPassword, unlockWithRecovery, + generateIdentityKeyPair, + wrapIdentityPrivateKey, + unwrapIdentityPrivateKey, + generateProfileDek, + wrapProfileDek, + unwrapProfileDek, + wrapProfileDekToRecipient, + unwrapProfileDekFromShare, + type SharedProfileDekEnvelope, setEncKey, getEncKey, clearEncKey, hasEncKey, + setIdentityPrivate, + getIdentityPrivate, + clearIdentityPrivate, + hasIdentityPrivate, + setProfileDek, + getProfileDek, + setActiveProfileId, + getActiveProfileId, + getActiveProfileDek, + clearProfileDeks, AUTH_SALT, PASSWORD_KEK_SALT, RECOVERY_KEK_SALT, diff --git a/web/normogen-web/src/crypto/keys.ts b/web/normogen-web/src/crypto/keys.ts index 25577a5..b96d09d 100644 --- a/web/normogen-web/src/crypto/keys.ts +++ b/web/normogen-web/src/crypto/keys.ts @@ -114,6 +114,130 @@ export async function rewrapDek( return wrapDek(dek, newKek); } +// --------------------------------------------------------------------------- +// Per-profile DEK wrap/unwrap (Phase A2). +// +// Each profile owns a random AES-256-GCM DEK that encrypts all of that +// profile's data. The profile DEK is wrapped under the *account* DEK (not a +// KEK derived from a secret) and stored on the server. At login/unlock the +// client unwraps each profile DEK using the account DEK. See +// docs/adr/multi-person-sharing.md §1. +// --------------------------------------------------------------------------- + +/** Generate a fresh random profile DEK (a random AES-256-GCM key). */ +export async function generateProfileDek(): Promise { + return generateDek(); +} + +/** Wrap a profile DEK under the account DEK. The account DEK is a CryptoKey + * usable for AES-GCM encrypt/decrypt, so it serves directly as the wrap key. */ +export async function wrapProfileDek( + profileDek: CryptoKey, + accountDek: CryptoKey, +): Promise { + return wrapDek(profileDek, accountDek); +} + +/** Unwrap a profile DEK from its account-wrapped form. Inverse of + * wrapProfileDek. Throws on tamper / wrong account DEK. */ +export async function unwrapProfileDek( + payload: CipherPayload, + accountDek: CryptoKey, +): Promise { + return unwrapDek(payload, accountDek); +} + +// --------------------------------------------------------------------------- +// Profile sharing: X25519 envelope (Phase B). +// +// To share a profile, the owner generates a fresh ephemeral X25519 keypair, +// ECDH-derives a shared secret from (ephemeral private, recipient public), +// uses it as an AES-GCM wrapping key, and wraps the profile DEK. The server +// stores the wrapped DEK + the ephemeral public key (plaintext). The recipient +// ECDH-derives the same shared secret from (their private, ephemeral public) +// and unwraps the profile DEK. See docs/adr/multi-person-sharing.md §3. +// --------------------------------------------------------------------------- + +/** Shape returned by the owner-side share wrapping. */ +export interface SharedProfileDekEnvelope { + /** base64 ephemeral X25519 public key (send to the server plaintext). */ + ephemeralPublicKey: string; + /** Profile DEK wrapped under the ECDH-derived key. */ + wrappedProfileDek: CipherPayload; +} + +/** Import a base64-raw X25519 public key for ECDH. */ +async function importX25519Public(publicKeyB64: string): Promise { + const raw = Uint8Array.from(atob(publicKeyB64), (c) => c.charCodeAt(0)); + return crypto.subtle.importKey( + 'raw', + raw as BufferSource, + { name: 'ECDH', namedCurve: 'X25519' }, + true, + [], + ); +} + +/** Owner: wrap a profile DEK to a recipient's identity public key. Generates + * a fresh ephemeral keypair per share. Requires the owner's identity private + * key (in-memory via getIdentityPrivate()). */ +export async function wrapProfileDekToRecipient( + profileDek: CryptoKey, + recipientPublicKeyB64: string, + myIdentityPrivate: CryptoKey, +): Promise { + // Fresh ephemeral keypair for this share (forward secrecy within its lifetime). + const ephemeral = (await crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'X25519' }, + true, + ['deriveBits'], + )) as CryptoKeyPair; + const recipientPub = await importX25519Public(recipientPublicKeyB64); + // ECDH(ephemeral private, recipient public) -> shared secret -> AES-GCM key. + const sharedBits = await crypto.subtle.deriveBits( + { name: 'ECDH', public: recipientPub }, + ephemeral.privateKey, + 256, + ); + const wrapKey = await crypto.subtle.importKey( + 'raw', + sharedBits, + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'], + ); + const wrappedProfileDek = await wrapDek(profileDek, wrapKey); + const rawEphemeralPub = await crypto.subtle.exportKey('raw', ephemeral.publicKey); + return { + ephemeralPublicKey: base64(new Uint8Array(rawEphemeralPub)), + wrappedProfileDek, + }; +} + +/** Recipient: unwrap a profile DEK from a share using the recipient's identity + * private key + the share's ephemeral public key. Inverse of + * wrapProfileDekToRecipient. Throws on tamper / wrong keys. */ +export async function unwrapProfileDekFromShare( + wrapped: CipherPayload, + ephemeralPublicKeyB64: string, + myIdentityPrivate: CryptoKey, +): Promise { + const ephemeralPub = await importX25519Public(ephemeralPublicKeyB64); + const sharedBits = await crypto.subtle.deriveBits( + { name: 'ECDH', public: ephemeralPub }, + myIdentityPrivate, + 256, + ); + const wrapKey = await crypto.subtle.importKey( + 'raw', + sharedBits, + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'], + ); + return unwrapDek(wrapped, wrapKey); +} + // --------------------------------------------------------------------------- // High-level flows // --------------------------------------------------------------------------- @@ -185,6 +309,62 @@ export async function unlockWithRecovery( return unwrapDek(recoveryWrappedDek, recoveryKek); } +// --------------------------------------------------------------------------- +// Account X25519 identity keypair (Phase A1, multi-person sharing prep). +// +// Each account gets an X25519 keypair at registration. The PUBLIC half is +// stored in plaintext (public keys are not secret); other accounts will wrap +// profile DEKs to it in Phase B. The PRIVATE half is wrapped (AES-GCM +// encrypted) under the account DEK and stored on the server as opaque +// ciphertext. The server never sees the private key. +// +// See docs/adr/multi-person-sharing.md §1. +// --------------------------------------------------------------------------- + +/** Generate an X25519 keypair for the account. Returns the public key as a + * base64 raw string (for the server to store) and the private key as a + * CryptoKey (kept in memory, wrapped before upload). */ +export async function generateIdentityKeyPair(): Promise<{ + publicKey: string; + privateKey: CryptoKey; +}> { + const { publicKey, privateKey } = (await crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'X25519' }, + true, // extractable so we can export the private key for wrapping + ['deriveBits'], + )) as CryptoKeyPair; + const rawPublic = await crypto.subtle.exportKey('raw', publicKey); + return { publicKey: base64(new Uint8Array(rawPublic)), privateKey }; +} + +/** Wrap (encrypt) the X25519 private key under the account DEK. The DEK is an + * AES-GCM key, so we reuse the string-cipher from cipher.ts: export the private + * key to raw bytes, base64-encode, and encrypt. */ +export async function wrapIdentityPrivateKey( + privateKey: CryptoKey, + dek: CryptoKey, +): Promise { + const rawPrivate = await crypto.subtle.exportKey('raw', privateKey); + return encrypt(base64(new Uint8Array(rawPrivate)), dek); +} + +/** Unwrap (decrypt) the X25519 private key from its DEK-wrapped form. Inverse + * of wrapIdentityPrivateKey. The result is importable for ECDH deriveBits. */ +export async function unwrapIdentityPrivateKey( + payload: CipherPayload, + dek: CryptoKey, +): Promise { + const rawPrivateB64 = await decrypt(payload, dek); + const rawPrivate = Uint8Array.from(atob(rawPrivateB64), (c) => c.charCodeAt(0)); + return crypto.subtle.importKey( + 'raw', + rawPrivate as BufferSource, + { name: 'ECDH', namedCurve: 'X25519' }, + true, // extractable so it can be re-wrapped if ever needed + ['deriveBits'], + ); +} + // --------------------------------------------------------------------------- // In-memory key store (unchanged from Phase 1) // --------------------------------------------------------------------------- @@ -207,6 +387,67 @@ export function hasEncKey(): boolean { return currentEncKey !== null; } +// --------------------------------------------------------------------------- +// In-memory identity-private-key store (Phase A1). Same lifecycle as the DEK: +// held only for the authenticated session, cleared on logout, re-derived from +// the wrapped form (under the account DEK) on unlock. Not consumed until Phase B. +// --------------------------------------------------------------------------- + +let currentIdentityPrivate: CryptoKey | null = null; + +export function setIdentityPrivate(key: CryptoKey): void { + currentIdentityPrivate = key; +} + +export function getIdentityPrivate(): CryptoKey | null { + return currentIdentityPrivate; +} + +export function clearIdentityPrivate(): void { + currentIdentityPrivate = null; +} + +export function hasIdentityPrivate(): boolean { + return currentIdentityPrivate !== null; +} + +// --------------------------------------------------------------------------- +// In-memory per-profile DEK store (Phase A2). One DEK per profile, plus a +// notion of the "active" profile whose DEK encrypts/decrypts the data the UI +// is currently showing. Same lifecycle as the account DEK: rebuilt from the +// server-stored wrapped forms on unlock, cleared on logout. +// --------------------------------------------------------------------------- + +const profileDeks: Map = new Map(); +let activeProfileId: string | null = null; + +export function setProfileDek(profileId: string, dek: CryptoKey): void { + profileDeks.set(profileId, dek); +} + +export function getProfileDek(profileId: string): CryptoKey | null { + return profileDeks.get(profileId) ?? null; +} + +export function setActiveProfileId(profileId: string): void { + activeProfileId = profileId; +} + +export function getActiveProfileId(): string | null { + return activeProfileId; +} + +/** The active profile's DEK — the key all encrypt/decrypt call sites use. */ +export function getActiveProfileDek(): CryptoKey | null { + if (activeProfileId === null) return null; + return profileDeks.get(activeProfileId) ?? null; +} + +export function clearProfileDeks(): void { + profileDeks.clear(); + activeProfileId = null; +} + // --------------------------------------------------------------------------- // Backward compat: the old deriveAuthAndEncKeys (Phase 1 direct-from-password // enc key). Kept for tests; new code uses setupEncryption/unlockWithPassword. diff --git a/web/normogen-web/src/pages/Dashboard.tsx b/web/normogen-web/src/pages/Dashboard.tsx index dfabf52..4f22b01 100644 --- a/web/normogen-web/src/pages/Dashboard.tsx +++ b/web/normogen-web/src/pages/Dashboard.tsx @@ -6,6 +6,7 @@ import { MedicationManager } from '../components/medication/MedicationManager'; import { HealthStats } from '../components/health/HealthStats'; import { InteractionsChecker } from '../components/interactions/InteractionsChecker'; import { ProfileEditor } from '../components/profile/ProfileEditor'; +import { ProfileSwitcher } from '../components/profile/ProfileSwitcher'; import { AppointmentsManager } from '../components/appointments/AppointmentsManager'; type TabIndex = 0 | 1 | 2 | 3 | 4; @@ -35,6 +36,7 @@ export const Dashboard: FC = () => { Normogen + {user && ( {user.username} diff --git a/web/normogen-web/src/pages/UnlockPage.tsx b/web/normogen-web/src/pages/UnlockPage.tsx index 2752138..8e3b0a3 100644 --- a/web/normogen-web/src/pages/UnlockPage.tsx +++ b/web/normogen-web/src/pages/UnlockPage.tsx @@ -12,11 +12,23 @@ import { } from '@mui/material'; import { Lock as LockIcon } from '@mui/icons-material'; import { useAuthStore } from '../store/useStore'; -import { unlockWithPassword, deriveAuthAndEncKeys, setEncKey } from '../crypto'; +import { + unlockWithPassword, + deriveAuthAndEncKeys, + unwrapIdentityPrivateKey, + setEncKey, + setIdentityPrivate, +} from '../crypto'; export const UnlockPage: FC = () => { const navigate = useNavigate(); - const { wrapped_dek, wrapped_dek_iv, user } = useAuthStore(); + const { + wrapped_dek, + wrapped_dek_iv, + identity_private_key_wrapped, + identity_private_key_wrapped_iv, + user, + } = useAuthStore(); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [unlocking, setUnlocking] = useState(false); @@ -27,13 +39,32 @@ export const UnlockPage: FC = () => { setUnlocking(true); try { + let dek; if (wrapped_dek && wrapped_dek_iv) { // Wrapped-DEK model: unwrap the DEK using the password. - const { dek } = await unlockWithPassword(password, { + ({ dek } = await unlockWithPassword(password, { data: wrapped_dek, iv: wrapped_dek_iv, - }); + })); setEncKey(dek); + // Phase A1: with the DEK available, also unwrap the X25519 identity + // private key into in-memory storage. Non-fatal if it fails (e.g. + // legacy accounts without an identity keypair). + if (identity_private_key_wrapped && identity_private_key_wrapped_iv) { + try { + const identityPrivate = await unwrapIdentityPrivateKey( + { + data: identity_private_key_wrapped, + iv: identity_private_key_wrapped_iv, + }, + dek, + ); + setIdentityPrivate(identityPrivate); + } catch { + // Wrapped private key present but undecryptable — ignore; Phase B + // features will surface a re-setup need. + } + } } else { // Phase 1 compat: derive the key directly from the password. const { encKey } = await deriveAuthAndEncKeys(password); diff --git a/web/normogen-web/src/services/api.ts b/web/normogen-web/src/services/api.ts index 344586c..12c5bc5 100644 --- a/web/normogen-web/src/services/api.ts +++ b/web/normogen-web/src/services/api.ts @@ -18,6 +18,14 @@ import { MedicationWireResponse, AppointmentWireResponse, ProfileWireResponse, + CreateProfileRequest, + UpdateProfileRequest, + SharedProfileWireResponse, + CreateProfileShareRequest, + ProfileShareListing, + PublicKeyResponse, + RekeyRequest, + RekeyResponse, HealthStatWireResponse, UpdateHealthStatRequest, EncryptedFieldWire, @@ -232,25 +240,93 @@ class ApiService { }); } - // ---- Profile (zero-knowledge: opaque encrypted name) ---- + // ---- Profiles (Phase A2: multi-profile, per-profile DEKs) ---- - async getProfile(): Promise { - const response = await this.client.get('/profiles/me'); + async listProfiles(): Promise { + const response = await this.client.get('/profiles'); return response.data; } - async updateProfileName(nameData: string, nameIv: string): Promise { - const response = await this.client.put('/profiles/me', { - name_data: nameData, - name_iv: nameIv, + async createProfile(req: CreateProfileRequest): Promise { + const response = await this.client.post('/profiles', req); + return response.data; + } + + async getProfile(profileId: string): Promise { + const response = await this.client.get(`/profiles/${profileId}`); + return response.data; + } + + async updateProfile(profileId: string, req: UpdateProfileRequest): Promise { + const response = await this.client.put(`/profiles/${profileId}`, req); + return response.data; + } + + async deleteProfile(profileId: string): Promise { + await this.client.delete(`/profiles/${profileId}`); + } + + // ---- Profile sharing (Phase B: X25519 envelope) ---- + + /** Public endpoint — recipient identity public key lookup by email. */ + async getUserPublicKey(email: string): Promise { + const response = await this.client.get('/users/public-key', { + params: { email }, }); return response.data; } + /** Profiles shared TO the current user. Carries ECDH-wrapped profile DEKs. */ + async listSharedWithMe(): Promise { + const response = await this.client.get( + '/profiles/shared-with-me', + ); + return response.data; + } + + /** Owner: list who a profile has been shared with. */ + async listProfileShares(profileId: string): Promise { + const response = await this.client.get( + `/profiles/${profileId}/shares`, + ); + return response.data; + } + + /** Owner: share a profile. The wrapped DEK + ephemeral pubkey are produced + * client-side via wrapProfileDekToRecipient. */ + async createProfileShare( + profileId: string, + req: CreateProfileShareRequest, + ): Promise { + const response = await this.client.post( + `/profiles/${profileId}/shares`, + req, + ); + return response.data; + } + + /** Owner: revoke a share (soft revoke — server deletes the share record). */ + async deleteProfileShare(profileId: string, recipientUserId: string): Promise { + 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(): Promise { - const response = await this.client.get('/medications'); + async getMedications(profileId?: string): Promise { + const params = profileId ? { profile_id: profileId } : undefined; + const response = await this.client.get('/medications', { params }); return response.data; } @@ -287,9 +363,12 @@ class ApiService { // ---- Appointments (zero-knowledge: opaque encrypted blobs) ---- - async getAppointments(status?: string): Promise { + async getAppointments(status?: string, profileId?: string): Promise { + const params: Record = {}; + if (status) params.status = status; + if (profileId) params.profile_id = profileId; const response = await this.client.get('/appointments', { - params: status ? { status } : undefined, + params: Object.keys(params).length ? params : undefined, }); return response.data; } @@ -332,8 +411,9 @@ class ApiService { // ---- Health Statistics (zero-knowledge: opaque encrypted blobs) ---- - async getHealthStats(): Promise { - const response = await this.client.get('/health-stats'); + async getHealthStats(profileId?: string): Promise { + const params = profileId ? { profile_id: profileId } : undefined; + const response = await this.client.get('/health-stats', { params }); return response.data; } diff --git a/web/normogen-web/src/store/useStore.test.ts b/web/normogen-web/src/store/useStore.test.ts index 059b750..26cb6d7 100644 --- a/web/normogen-web/src/store/useStore.test.ts +++ b/web/normogen-web/src/store/useStore.test.ts @@ -3,6 +3,9 @@ import { deriveAuthAndEncKeys, setEncKey, clearEncKey, + setActiveProfileId, + setProfileDek, + clearProfileDeks, encryptJson, setupEncryption, unlockWithPassword, @@ -22,6 +25,10 @@ vi.mock('../services/api', () => ({ default: apiMock })); // Import the store AFTER the mock is registered. const { useMedicationStore } = await import('./useStore'); +// The active profile under test — the store encrypts/decrypts with the active +// profile's DEK, so tests must set both the profile id and its DEK. +const TEST_PROFILE_ID = 'profile_test'; + describe('useMedicationStore', () => { let encKey: CryptoKey; @@ -30,6 +37,10 @@ describe('useMedicationStore', () => { const { encKey: key } = await deriveAuthAndEncKeys('test-password'); encKey = key; setEncKey(encKey); + // Phase A2: the store uses the active profile's DEK, not the account DEK. + // Register the test key as the active profile's DEK. + setProfileDek(TEST_PROFILE_ID, encKey); + setActiveProfileId(TEST_PROFILE_ID); useMedicationStore.setState({ medications: [], @@ -44,6 +55,7 @@ describe('useMedicationStore', () => { afterEach(() => { clearEncKey(); + clearProfileDeks(); }); it('loadMedications decrypts wire responses into domain medications', async () => { @@ -74,6 +86,7 @@ describe('useMedicationStore', () => { it('loadMedications sets an error when no enc key is available', async () => { clearEncKey(); + clearProfileDeks(); await useMedicationStore.getState().loadMedications(); expect(useMedicationStore.getState().error).toMatch(/No encryption key/); }); diff --git a/web/normogen-web/src/store/useStore.ts b/web/normogen-web/src/store/useStore.ts index 69211ee..22daa32 100644 --- a/web/normogen-web/src/store/useStore.ts +++ b/web/normogen-web/src/store/useStore.ts @@ -6,9 +6,28 @@ import { unlockWithPassword, unlockWithRecovery, rewrapDek, + generateIdentityKeyPair, + wrapIdentityPrivateKey, + unwrapIdentityPrivateKey, + generateProfileDek, + wrapProfileDek, + unwrapProfileDek, + wrapProfileDekToRecipient, + unwrapProfileDekFromShare, + encrypt as encryptRaw, + decrypt as decryptRaw, setEncKey, clearEncKey, + clearIdentityPrivate, + clearProfileDeks, + setIdentityPrivate, + getIdentityPrivate, getEncKey, + getActiveProfileDek, + getActiveProfileId, + setActiveProfileId, + setProfileDek, + getProfileDek, encryptJson, decryptJson, type CipherPayload, @@ -21,6 +40,10 @@ import { DrugInteraction, AdherenceStats, Profile, + ProfileShareListing, + MedicationWireResponse, + AppointmentWireResponse, + HealthStatWireResponse, Appointment, CreateAppointmentRequest, UpdateAppointmentRequest, @@ -38,6 +61,12 @@ interface AuthState { // on page reload without a full re-login. wrapped_dek: string | null; wrapped_dek_iv: string | null; + // Account X25519 identity keypair (Phase A1). The public key is plaintext; + // the private key is the DEK-wrapped ciphertext blob. Persisted for the same + // reason as the wrapped DEK — the unlock screen unwraps it into memory. + identity_public_key: string | null; + identity_private_key_wrapped: string | null; + identity_private_key_wrapped_iv: string | null; // Actions login: (email: string, password: string) => Promise; @@ -95,11 +124,46 @@ interface InteractionState { } interface ProfileState { - profile: Profile | null; + profiles: Profile[]; + activeProfileId: string | null; isLoading: boolean; error: string | null; - loadProfile: () => Promise; - updateName: (name: string) => Promise; + /** Fetch all owned profiles and unwrap each per-profile DEK under the account + * DEK into the in-memory crypto store. Sets the first profile active. */ + loadProfiles: () => Promise; + /** Switch the active profile (whose DEK encrypts/decrypts the UI's data). */ + setActiveProfile: (profileId: string) => void; + createProfile: (input: { + name: string; + kind?: string; + relationship?: string; + }) => Promise; + updateProfile: (profileId: string, input: { + name: string; + kind?: string; + relationship?: string; + }) => Promise; + deleteProfile: (profileId: string) => Promise; + /** Fetch profiles shared TO the current user and merge them into `profiles`, + * unwrapping each share's profile DEK via the recipient's identity private + * key. Called after loadProfiles on login/unlock. */ + loadSharedWithMe: () => Promise; + /** Owner: share a profile to a recipient by email. Fetches the recipient's + * identity public key, wraps the profile DEK to it via ECDH, and POSTs. */ + shareProfile: (profileId: string, recipientEmail: string) => Promise; + /** Owner: revoke a share (delete the share record server-side). */ + revokeShare: (profileId: string, recipientUserId: string) => Promise; + /** 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; } @@ -128,6 +192,9 @@ export const useAuthStore = create()( error: null, wrapped_dek: null, wrapped_dek_iv: null, + identity_public_key: null, + identity_private_key_wrapped: null, + identity_private_key_wrapped_iv: null, login: async (email: string, password: string) => { set({ isLoading: true, error: null }); @@ -144,6 +211,26 @@ export const useAuthStore = create()( iv: response.wrapped_dek_iv, }); setEncKey(dek); + // Once the DEK is available, also unwrap the X25519 identity + // private key (Phase A1) into in-memory storage. + if ( + response.identity_private_key_wrapped && + response.identity_private_key_wrapped_iv + ) { + try { + const identityPrivate = await unwrapIdentityPrivateKey( + { + data: response.identity_private_key_wrapped, + iv: response.identity_private_key_wrapped_iv, + }, + dek, + ); + setIdentityPrivate(identityPrivate); + } catch { + // Wrapped private key present but failed to unwrap — + // non-fatal; Phase B features will surface a re-setup need. + } + } } catch { // Fallback to the PBKDF2-derived key (Phase 1 compat). } @@ -160,6 +247,9 @@ export const useAuthStore = create()( isLoading: false, wrapped_dek: response.wrapped_dek ?? null, wrapped_dek_iv: response.wrapped_dek_iv ?? null, + identity_public_key: response.identity_public_key ?? null, + identity_private_key_wrapped: response.identity_private_key_wrapped ?? null, + identity_private_key_wrapped_iv: response.identity_private_key_wrapped_iv ?? null, }); } catch (error: any) { clearEncKey(); @@ -181,6 +271,21 @@ export const useAuthStore = create()( const setup = await setupEncryption(password, recoveryPhrase); setEncKey(setup.dek); + // Phase A1: generate the account X25519 identity keypair and wrap + // the private half under the account DEK. The public key is sent + // plaintext; the wrapped private key is opaque ciphertext. + const { publicKey, privateKey } = await generateIdentityKeyPair(); + const wrappedPrivate = await wrapIdentityPrivateKey(privateKey, setup.dek); + setIdentityPrivate(privateKey); + + // Phase A2: generate the default "self" profile's DEK, wrap it + // under the account DEK, and encrypt the display name (the chosen + // username) under the profile DEK. The server stores both opaque + // blobs verbatim and auto-creates the self profile. + const defaultProfileDek = await generateProfileDek(); + const defaultWrapped = await wrapProfileDek(defaultProfileDek, setup.dek); + const defaultNameBlob = await encryptRaw(username, defaultProfileDek); + const response = await apiService.register({ username, email, @@ -190,7 +295,19 @@ export const useAuthStore = create()( recovery_wrapped_dek: setup.recoveryWrappedDek?.data, recovery_wrapped_dek_iv: setup.recoveryWrappedDek?.iv, recovery_phrase: setup.recoveryKekHash, - } as any); + identity_public_key: publicKey, + identity_private_key_wrapped: wrappedPrivate.data, + identity_private_key_wrapped_iv: wrappedPrivate.iv, + default_profile_name_data: defaultNameBlob.data, + default_profile_name_iv: defaultNameBlob.iv, + default_wrapped_profile_dek: defaultWrapped.data, + default_wrapped_profile_dek_iv: defaultWrapped.iv, + }); + // Seed the in-memory profile-DEK store with the self profile and + // make it the active profile. + const selfProfileId = `profile_${response.user_id}`; + setProfileDek(selfProfileId, defaultProfileDek); + setActiveProfileId(selfProfileId); set({ user: { user_id: response.user_id, @@ -203,6 +320,9 @@ export const useAuthStore = create()( isLoading: false, wrapped_dek: setup.passwordWrappedDek.data, wrapped_dek_iv: setup.passwordWrappedDek.iv, + identity_public_key: publicKey, + identity_private_key_wrapped: wrappedPrivate.data, + identity_private_key_wrapped_iv: wrappedPrivate.iv, }); } catch (error: any) { set({ @@ -254,6 +374,8 @@ export const useAuthStore = create()( logout: async () => { clearEncKey(); + clearIdentityPrivate(); + clearProfileDeks(); await apiService.logout(); set({ user: null, @@ -262,6 +384,9 @@ export const useAuthStore = create()( error: null, wrapped_dek: null, wrapped_dek_iv: null, + identity_public_key: null, + identity_private_key_wrapped: null, + identity_private_key_wrapped_iv: null, }); }, @@ -296,6 +421,9 @@ export const useAuthStore = create()( isAuthenticated: state.isAuthenticated, wrapped_dek: state.wrapped_dek, wrapped_dek_iv: state.wrapped_dek_iv, + identity_public_key: state.identity_public_key, + identity_private_key_wrapped: state.identity_private_key_wrapped, + identity_private_key_wrapped_iv: state.identity_private_key_wrapped_iv, }), } ) @@ -312,14 +440,14 @@ export const useMedicationStore = create()( adherence: {}, loadMedications: async () => { - const key = getEncKey(); + const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — log in to decrypt data', isLoading: false }); return; } set({ isLoading: true, error: null }); try { - const wireMeds = await apiService.getMedications(); + const wireMeds = await apiService.getMedications(getActiveProfileId() ?? undefined); // Decrypt each opaque blob into domain Medication objects. const medications = await Promise.all( wireMeds.map(async (w) => { @@ -359,7 +487,7 @@ export const useMedicationStore = create()( }, createMedication: async (data: any) => { - const key = getEncKey(); + const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); @@ -409,7 +537,7 @@ export const useMedicationStore = create()( }, updateMedication: async (id: string, data: any) => { - const key = getEncKey(); + const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); @@ -508,14 +636,14 @@ export const useHealthStore = create()( error: null, loadStats: async () => { - const key = getEncKey(); + const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — log in to decrypt data', isLoading: false }); return; } set({ isLoading: true, error: null }); try { - const wireStats = await apiService.getHealthStats(); + const wireStats = await apiService.getHealthStats(getActiveProfileId() ?? undefined); const stats = await Promise.all( wireStats.map(async (w) => { const data = await decryptJson>(w.encrypted_data, key); @@ -542,7 +670,7 @@ export const useHealthStore = create()( }, createStat: async (data: any) => { - const key = getEncKey(); + const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); @@ -550,6 +678,7 @@ export const useHealthStore = create()( set({ isLoading: true, error: null }); try { const d = data as any; + const profileId = getActiveProfileId(); const encrypted_data = await encryptJson({ stat_type: d.stat_type, value: d.value, @@ -557,6 +686,7 @@ export const useHealthStore = create()( notes: d.notes, }, key); const wire = await apiService.createHealthStat({ + profile_id: profileId ?? '', encrypted_data, recorded_at: d.measured_at, }); @@ -583,7 +713,7 @@ export const useHealthStore = create()( }, updateStat: async (id: string, data: any) => { - const key = getEncKey(); + const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); @@ -695,71 +825,159 @@ export const useInteractionStore = create()( })) ); -// Profile Store (Phase 3c) +// Profile Store (Phase A2: multi-profile + per-profile DEKs) export const useProfileStore = create()( devtools((set, get) => ({ - profile: null, + profiles: [], + activeProfileId: null, isLoading: false, error: null, + profileShares: {}, - loadProfile: async () => { - const key = getEncKey(); - if (!key) { - set({ error: 'No encryption key — log in to decrypt data', isLoading: false }); + loadProfiles: async () => { + const accountDek = getEncKey(); + if (!accountDek) { + set({ error: 'No account key — log in to decrypt profiles', isLoading: false }); return; } set({ isLoading: true, error: null }); try { - const wire = await apiService.getProfile(); - // Decrypt the profile name. - let name = ''; - if (wire.name_data && wire.name_iv) { - try { - name = await decryptJson({ data: wire.name_data, iv: wire.name_iv }, key); - } catch { name = ''; } + const wireList = await apiService.listProfiles(); + // Unwrap each profile's DEK under the account DEK, and decrypt each + // profile's display name under that profile's DEK. + const profiles: Profile[] = []; + for (const wire of wireList) { + let profileDek = getProfileDek(wire.profile_id); + if (!profileDek) { + try { + profileDek = await unwrapProfileDek( + { data: wire.wrapped_profile_dek, iv: wire.wrapped_profile_dek_iv }, + accountDek, + ); + setProfileDek(wire.profile_id, profileDek); + } catch { + // Could not unwrap this profile's DEK — skip it; the UI will + // show the profiles it could decrypt. + continue; + } + } + let name = ''; + if (wire.name_data && wire.name_iv && profileDek) { + try { + name = await decryptRaw({ data: wire.name_data, iv: wire.name_iv }, profileDek); + } catch { name = ''; } + } + profiles.push({ + profile_id: wire.profile_id, + owner_account_id: wire.owner_account_id, + name, + kind: wire.kind, + relationship: wire.relationship, + role: wire.role, + permissions: wire.permissions, + created_at: wire.created_at, + updated_at: wire.updated_at, + }); } - const profile: Profile = { - profile_id: wire.profile_id, - user_id: wire.user_id, - name, - role: wire.role, - permissions: wire.permissions, - created_at: wire.created_at, - updated_at: wire.updated_at, - }; - set({ profile, isLoading: false }); + // Set the first profile active if none is selected (or if the active + // one is no longer present). + const currentActive = getActiveProfileId(); + const stillOwned = profiles.some((p) => p.profile_id === currentActive); + const newActive = stillOwned ? currentActive : (profiles[0]?.profile_id ?? null); + if (newActive) setActiveProfileId(newActive); + set({ profiles, activeProfileId: newActive, isLoading: false }); } catch (error: any) { set({ - error: error.message || 'Failed to load profile', + error: error.message || 'Failed to load profiles', isLoading: false, }); } }, - updateName: async (name) => { - const key = getEncKey(); - if (!key) { - set({ error: 'No encryption key — cannot encrypt data' }); - throw new Error('No encryption key'); + setActiveProfile: (profileId) => { + setActiveProfileId(profileId); + set({ activeProfileId: profileId }); + }, + + createProfile: async ({ name, kind = 'human', relationship = '' }) => { + const accountDek = getEncKey(); + if (!accountDek) { + set({ error: 'No account key — cannot create profile' }); + throw new Error('No account key'); } set({ isLoading: true, error: null }); try { - const blob = await encryptJson(name, key); - const wire = await apiService.updateProfileName(blob.data, blob.iv); - let decryptedName = name; - try { - decryptedName = await decryptJson({ data: wire.name_data, iv: wire.name_iv }, key); - } catch { /* keep the input name */ } + // Generate a fresh profile DEK, wrap it under the account DEK, and + // encrypt the display name under the new profile DEK. + const profileDek = await generateProfileDek(); + const wrapped = await wrapProfileDek(profileDek, accountDek); + const nameBlob = await encryptRaw(name, profileDek); + const wire = await apiService.createProfile({ + kind, + relationship, + name_data: nameBlob.data, + name_iv: nameBlob.iv, + wrapped_profile_dek: wrapped.data, + wrapped_profile_dek_iv: wrapped.iv, + }); + // Cache the DEK in memory (we already have it) and set this profile + // active — the user just created it. + setProfileDek(wire.profile_id, profileDek); + setActiveProfileId(wire.profile_id); const profile: Profile = { profile_id: wire.profile_id, - user_id: wire.user_id, - name: decryptedName, + owner_account_id: wire.owner_account_id, + name, + kind: wire.kind, + relationship: wire.relationship, role: wire.role, permissions: wire.permissions, created_at: wire.created_at, updated_at: wire.updated_at, }; - set({ profile, isLoading: false }); + set((state) => ({ + profiles: [...state.profiles, profile], + activeProfileId: wire.profile_id, + isLoading: false, + })); + } catch (error: any) { + set({ + error: error.message || 'Failed to create profile', + isLoading: false, + }); + throw error; + } + }, + + updateProfile: async (profileId, { name, kind, relationship }) => { + const profileDek = getProfileDek(profileId); + if (!profileDek) { + set({ error: 'No profile key — switch to or unlock this profile first' }); + throw new Error('No profile key'); + } + set({ isLoading: true, error: null }); + try { + const nameBlob = await encryptRaw(name, profileDek); + const wire = await apiService.updateProfile(profileId, { + name_data: nameBlob.data, + name_iv: nameBlob.iv, + kind: kind ?? 'human', + relationship: relationship ?? '', + }); + set((state) => ({ + profiles: state.profiles.map((p) => + p.profile_id === profileId + ? { + ...p, + name, + kind: wire.kind, + relationship: wire.relationship, + updated_at: wire.updated_at, + } + : p, + ), + isLoading: false, + })); } catch (error: any) { set({ error: error.message || 'Failed to update profile', @@ -769,6 +987,272 @@ export const useProfileStore = create()( } }, + deleteProfile: async (profileId) => { + set({ isLoading: true, error: null }); + try { + await apiService.deleteProfile(profileId); + const remaining = get().profiles.filter((p) => p.profile_id !== profileId); + // If we deleted the active profile, fall back to the first remaining. + let newActive = getActiveProfileId(); + if (newActive === profileId) { + newActive = remaining[0]?.profile_id ?? null; + if (newActive) setActiveProfileId(newActive); + } + set({ profiles: remaining, activeProfileId: newActive, isLoading: false }); + } catch (error: any) { + set({ + error: error.message || 'Failed to delete profile', + isLoading: false, + }); + throw error; + } + }, + + loadSharedWithMe: async () => { + // Recipient path: profiles shared TO the current user. Each share's + // profile DEK is wrapped under an ECDH-derived key; unwrap it with the + // identity private key, then decrypt the display name under the profile + // DEK. Merge into the profiles list with is_shared = true. + const myIdentityPrivate = getIdentityPrivate(); + if (!myIdentityPrivate) { + // No identity key unlocked — can't unwrap shared profile DEKs. Silently + // skip; the owned-profile list still loads. + return; + } + try { + const shared = await apiService.listSharedWithMe(); + const sharedProfiles: Profile[] = []; + for (const s of shared) { + // Skip if we already have the DEK in memory (avoid re-unwrapping). + let profileDek = getProfileDek(s.profile_id); + if (!profileDek) { + try { + profileDek = await unwrapProfileDekFromShare( + { data: s.wrapped_profile_dek, iv: s.wrapped_profile_dek_iv }, + s.ephemeral_public_key, + myIdentityPrivate, + ); + setProfileDek(s.profile_id, profileDek); + } catch { + continue; // couldn't unwrap — skip this share + } + } + let name = ''; + if (s.name_data && s.name_iv && profileDek) { + try { + name = await decryptRaw({ data: s.name_data, iv: s.name_iv }, profileDek); + } catch { name = ''; } + } + sharedProfiles.push({ + profile_id: s.profile_id, + owner_account_id: s.owner_account_id, + name, + kind: s.kind, + relationship: s.relationship, + role: 'patient', + permissions: s.permissions, + created_at: s.created_at, + updated_at: s.created_at, + is_shared: true, + }); + } + // Merge: replace any prior shared entries, keep owned ones intact. + const owned = get().profiles.filter((p) => !p.is_shared); + const merged = [...owned, ...sharedProfiles]; + set({ profiles: merged }); + } catch (error: any) { + // Non-fatal: owned profiles still work. + set({ error: error.message || 'Failed to load shared profiles' }); + } + }, + + shareProfile: async (profileId, recipientEmail) => { + // Owner path: fetch recipient pubkey, wrap the profile DEK to it, POST. + const myIdentityPrivate = getIdentityPrivate(); + const profileDek = getProfileDek(profileId); + if (!myIdentityPrivate) { + set({ error: 'No identity key unlocked — cannot share' }); + throw new Error('No identity key'); + } + if (!profileDek) { + set({ error: 'No profile key — switch to the profile first' }); + throw new Error('No profile key'); + } + set({ isLoading: true, error: null }); + try { + const { identity_public_key: recipientPub } = + await apiService.getUserPublicKey(recipientEmail); + const envelope = await wrapProfileDekToRecipient( + profileDek, + recipientPub, + myIdentityPrivate, + ); + await apiService.createProfileShare(profileId, { + recipient_email: recipientEmail, + ephemeral_public_key: envelope.ephemeralPublicKey, + wrapped_profile_dek: envelope.wrappedProfileDek.data, + wrapped_profile_dek_iv: envelope.wrappedProfileDek.iv, + permissions: ['read'], + }); + // Refresh the share listing for this profile. + await get().loadProfileShares(profileId); + set({ isLoading: false }); + } catch (error: any) { + set({ + error: error.message || 'Failed to share profile', + isLoading: false, + }); + throw error; + } + }, + + revokeShare: async (profileId, recipientUserId) => { + set({ isLoading: true, error: null }); + try { + await apiService.deleteProfileShare(profileId, recipientUserId); + await get().loadProfileShares(profileId); + set({ isLoading: false }); + } catch (error: any) { + set({ + error: error.message || 'Failed to revoke share', + isLoading: false, + }); + throw error; + } + }, + + loadProfileShares: async (profileId) => { + try { + const shares = await apiService.listProfileShares(profileId); + set((state) => ({ + profileShares: { ...state.profileShares, [profileId]: shares }, + })); + } catch (error: any) { + set({ error: error.message || 'Failed to load shares' }); + } + }, + + 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 }), })), ); @@ -781,14 +1265,14 @@ export const useAppointmentStore = create()( error: null, loadAppointments: async (status) => { - const key = getEncKey(); + const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — log in to decrypt data', isLoading: false }); return; } set({ isLoading: true, error: null }); try { - const wireAppts = await apiService.getAppointments(status); + const wireAppts = await apiService.getAppointments(status, getActiveProfileId() ?? undefined); const appointments = await Promise.all( wireAppts.map(async (w) => { const data = await decryptJson>(w.encrypted_data, key); @@ -821,7 +1305,7 @@ export const useAppointmentStore = create()( }, createAppointment: async (data) => { - const key = getEncKey(); + const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); @@ -867,7 +1351,7 @@ export const useAppointmentStore = create()( }, updateAppointment: async (id, data) => { - const key = getEncKey(); + const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); diff --git a/web/normogen-web/src/types/api.ts b/web/normogen-web/src/types/api.ts index 67ccf49..9904310 100644 --- a/web/normogen-web/src/types/api.ts +++ b/web/normogen-web/src/types/api.ts @@ -42,6 +42,11 @@ export interface AuthTokens { email?: string; wrapped_dek?: string; wrapped_dek_iv?: string; + /** Account X25519 identity keypair (Phase A1). Public key is plaintext; + * private key is wrapped under the account DEK. Absent for legacy accounts. */ + identity_public_key?: string; + identity_private_key_wrapped?: string; + identity_private_key_wrapped_iv?: string; } export interface LoginRequest { @@ -54,6 +59,25 @@ export interface RegisterRequest { email: string; password: string; role?: 'patient' | 'provider' | 'admin'; + /** Zero-knowledge wrapped DEK (password KEK). */ + wrapped_dek?: string; + wrapped_dek_iv?: string; + /** Zero-knowledge wrapped DEK (recovery KEK). */ + recovery_wrapped_dek?: string; + recovery_wrapped_dek_iv?: string; + /** Recovery proof (PBKDF2 derivation of the recovery phrase). */ + recovery_phrase?: string; + /** Account X25519 identity keypair (Phase A1). */ + identity_public_key?: string; + identity_private_key_wrapped?: string; + identity_private_key_wrapped_iv?: string; + /** Default "self" profile (Phase A2). Encrypted name blob + profile DEK + * wrapped under the account DEK. */ + default_profile_name_data?: string; + default_profile_name_iv?: string; + default_profile_name_auth_tag?: string; + default_wrapped_profile_dek?: string; + default_wrapped_profile_dek_iv?: string; } // Medication Types @@ -223,11 +247,13 @@ export interface HealthStat { export interface HealthStatWireResponse { id: string; user_id: string; + profile_id: string; encrypted_data: EncryptedFieldWire; recorded_at: string; } export interface CreateHealthStatRequest { + profile_id: string; encrypted_data: EncryptedFieldWire; recorded_at?: string; } @@ -307,18 +333,110 @@ export interface UpdateAppointmentRequest { status?: string; } -// Profile wire response (opaque encrypted name) +// Profile wire response (opaque encrypted name + wrapped profile DEK) export interface ProfileWireResponse { profile_id: string; - user_id: string; + owner_account_id: string; name_data: string; name_iv: string; + kind: string; + relationship: string; role: string; permissions: string[]; + /** Profile DEK wrapped under the account DEK (opaque ciphertext). */ + wrapped_profile_dek: string; + wrapped_profile_dek_iv: string; created_at: string; updated_at: string; } +// Request body for POST /profiles +export interface CreateProfileRequest { + profile_id?: string; + kind?: string; + relationship?: string; + name_data: string; + name_iv: string; + name_auth_tag?: string; + wrapped_profile_dek: string; + wrapped_profile_dek_iv: string; +} + +// Request body for PUT /profiles/:id +export interface UpdateProfileRequest { + name_data: string; + name_iv: string; + name_auth_tag?: string; + kind?: string; + relationship?: string; +} + +// A profile shared TO the current user (recipient view). Carries the +// per-share ECDH-wrapped DEK the recipient unwraps with their identity +// private key (not the account-wrapped form owned profiles use). +export interface SharedProfileWireResponse { + profile_id: string; + owner_account_id: string; + name_data: string; + name_iv: string; + kind: string; + relationship: string; + ephemeral_public_key: string; + wrapped_profile_dek: string; + wrapped_profile_dek_iv: string; + permissions: string[]; + created_at: string; +} + +// Request body for POST /profiles/:id/shares (owner wraps client-side). +export interface CreateProfileShareRequest { + recipient_email: string; + ephemeral_public_key: string; + wrapped_profile_dek: string; + wrapped_profile_dek_iv: string; + permissions?: string[]; + expires_at?: string; +} + +// A share the owner has created (listing view). +export interface ProfileShareListing { + profile_id: string; + recipient_user_id: string; + recipient_email: string; + permissions: string[]; + created_at: string; + 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; + identity_public_key: string; +} + // Dose Log Types — match backend MedicationDose (camelCase serialization). export interface DoseLog { id?: string; @@ -351,12 +469,18 @@ export interface AdherenceStats { // Profile Types — match backend ProfileResponse. export interface Profile { profile_id: string; - user_id: string; + owner_account_id: string; name: string; + kind: string; + relationship: string; role: string; permissions: string[]; created_at: string; updated_at: string; + /** True if this profile is shared TO the current user (recipient view). + * Undefined/false for profiles the user owns. Set by the store when merging + * shared-with-me results. */ + is_shared?: boolean; } // Error Types