diff --git a/.gitignore b/.gitignore index 6098d17..bdfa7c1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ .env.local .env.*.local .forgejo-token -.zcode/ node_modules/ dist/ target/ diff --git a/backend/src/app.rs b/backend/src/app.rs index e300abe..fc19592 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -35,39 +35,28 @@ pub fn build_app(state: AppState) -> Router { "/api/auth/recover-password", post(handlers::recover_password), ) - .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)); + .route("/api/auth/recovery-info", get(handlers::recovery_info)); // Build protected routes (auth required) let protected_routes = Router::new() // User profile management - .route("/api/users/me", get(handlers::get_account)) - .route("/api/users/me", put(handlers::update_account)) + .route("/api/users/me", get(handlers::get_profile)) + .route("/api/users/me", put(handlers::update_profile)) .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)) - // 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)) + // 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)) // 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 new file mode 100644 index 0000000..2bdf5cc --- /dev/null +++ b/backend/src/auth/claims.rs @@ -0,0 +1,22 @@ +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 efe5383..04e3bbd 100644 --- a/backend/src/auth/password.rs +++ b/backend/src/auth/password.rs @@ -1,9 +1,6 @@ use anyhow::Result; use pbkdf2::{ - password_hash::{ - rand_core::OsRng, Error as PasswordHashError, PasswordHash, PasswordHasher, - PasswordVerifier, SaltString, - }, + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Pbkdf2, }; @@ -18,24 +15,14 @@ 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))?; - 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)), - } + Pbkdf2 + .verify_password(password.as_bytes(), &parsed_hash) + .map(|_| true) + .map_err(|e| anyhow::anyhow!("Password verification failed: {}", e)) } } diff --git a/backend/src/db/init.rs b/backend/src/db/init.rs index 6cdd71f..8193095 100644 --- a/backend/src/db/init.rs +++ b/backend/src/db/init.rs @@ -98,30 +98,15 @@ impl DatabaseInitializer { println!("✓ Created appointments collection"); } - // 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). + // Create shares collection and index { - let collection: Collection = - self.db.collection("profile_shares"); + let collection: Collection = self.db.collection("shares"); - 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), - } + let index = IndexModel::builder().keys(doc! { "familyId": 1 }).build(); - 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 - ), + 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), } } diff --git a/backend/src/db/mongodb_impl.rs b/backend/src/db/mongodb_impl.rs index 64952df..e1410ae 100644 --- a/backend/src/db/mongodb_impl.rs +++ b/backend/src/db/mongodb_impl.rs @@ -5,6 +5,8 @@ use std::time::Duration; use crate::models::{ medication::{Medication, MedicationDose, MedicationRepository, UpdateMedicationRequest}, + permission::Permission, + share::{Share, ShareRepository}, user::{User, UserRepository}, }; @@ -12,6 +14,7 @@ use crate::models::{ pub struct MongoDb { database: Database, pub users: Collection, + pub shares: Collection, pub medications: Collection, pub medication_doses: Collection, } @@ -59,6 +62,7 @@ impl MongoDb { return Ok(Self { users: database.collection("users"), + shares: database.collection("shares"), medications: database.collection("medications"), medication_doses: database.collection("medication_doses"), database, @@ -93,6 +97,7 @@ impl MongoDb { return Ok(Self { users: database.collection("users"), + shares: database.collection("shares"), medications: database.collection("medications"), medication_doses: database.collection("medication_doses"), database, @@ -108,6 +113,7 @@ impl MongoDb { Ok(Self { users: database.collection("users"), + shares: database.collection("shares"), medications: database.collection("medications"), medication_doses: database.collection("medication_doses"), database, @@ -171,6 +177,100 @@ 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 e95fbde..9d9f054 100644 --- a/backend/src/handlers/appointments.rs +++ b/backend/src/handlers/appointments.rs @@ -20,8 +20,6 @@ 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( @@ -65,175 +63,64 @@ pub async fn list_appointments( State(state): State, Extension(claims): Extension, Query(query): Query, -) -> Result>, (StatusCode, Json)> { +) -> Result>, StatusCode> { 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(&owner, query.status.as_deref(), query.profile_id.as_deref()) + .find_by_user_filtered(&claims.sub, query.status.as_deref()) .await { Ok(appointments) => { let resp: Vec = appointments.into_iter().map(Into::into).collect(); Ok(Json(resp)) } - Err(e) => { - tracing::error!("list_appointments failed: {}", e); - Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": "database error" })), - )) - } + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } pub async fn get_appointment( State(state): State, - Extension(claims): Extension, + Extension(_claims): Extension, Path(id): Path, -) -> Result, (StatusCode, Json)> { +) -> Result, StatusCode> { let database = state.db.get_database(); let repo = AppointmentRepository::new(database.collection("appointments")); - 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?; + 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), } - 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, Json)> { +) -> Result, StatusCode> { 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, - 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" })), - )) - } + Ok(None) => Err(StatusCode::NOT_FOUND), + Err(_) => Err(StatusCode::INTERNAL_SERVER_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, - 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" })), - )) - } + Ok(false) => Err(StatusCode::NOT_FOUND), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs index 1b7a2d4..cbe8836 100644 --- a/backend/src/handlers/auth.rs +++ b/backend/src/handlers/auth.rs @@ -27,20 +27,6 @@ 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)] @@ -54,12 +40,6 @@ 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( @@ -128,11 +108,6 @@ 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; @@ -154,29 +129,17 @@ pub async fn register( .await; } - // 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); - } + // 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); } id @@ -240,9 +203,6 @@ 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() @@ -453,9 +413,6 @@ 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 5397d8e..ca902cd 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, Query, State}, + extract::{Path, State}, http::StatusCode, response::IntoResponse, Extension, Json, @@ -14,16 +14,9 @@ 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, @@ -48,7 +41,6 @@ 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 @@ -78,7 +70,6 @@ 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, @@ -90,34 +81,7 @@ pub async fn list_health_stats( .into_response() } }; - // 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 - { + match repo.find_by_user(&claims.sub).await { Ok(stats) => { let resp: Vec = stats.into_iter().map(Into::into).collect(); (StatusCode::OK, Json(resp)).into_response() @@ -158,21 +122,10 @@ pub async fn get_health_stat( match repo.find_by_id(&object_id).await { Ok(Some(stat)) => { - // 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(), + if stat.user_id != claims.sub { + return (StatusCode::FORBIDDEN, "Access denied").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 c6bf0c5..0d70c0e 100644 --- a/backend/src/handlers/medications.rs +++ b/backend/src/handlers/medications.rs @@ -64,193 +64,70 @@ pub async fn list_medications( State(state): State, Extension(claims): Extension, Query(query): Query, -) -> Result>, (StatusCode, Json)> { +) -> Result>, StatusCode> { let database = state.db.get_database(); let repo = MedicationRepository::new(database.collection("medications")); - // 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(), - }; - + // Honor the active filter (and optional profile_id) when provided. match repo - .find_by_user_filtered(&owner, query.active, query.profile_id.as_deref()) + .find_by_user_filtered(&claims.sub, query.active, query.profile_id.as_deref()) .await { Ok(medications) => { let resp: Vec = medications.into_iter().map(Into::into).collect(); Ok(Json(resp)) } - Err(e) => { - tracing::error!("list_medications failed: {}", e); - Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": "database error" })), - )) - } + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } pub async fn get_medication( State(state): State, - Extension(claims): Extension, + Extension(_claims): Extension, Path(id): Path, -) -> Result, (StatusCode, Json)> { +) -> Result, StatusCode> { 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. - 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?; + 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), } - 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, Json)> { +) -> Result, StatusCode> { let database = state.db.get_database(); let repo = MedicationRepository::new(database.collection("medications")); - // 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" })), - )); - } - + // Look up by medication_id (UUID) — the path param is not a Mongo ObjectId. match repo.update_by_medication_id(&id, req).await { Ok(Some(medication)) => Ok(Json(medication.into())), - 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" })), - )) - } + Ok(None) => Err(StatusCode::NOT_FOUND), + Err(_) => Err(StatusCode::INTERNAL_SERVER_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")); - // 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" })), - )); - } - + // Look up by medication_id (UUID), not Mongo _id. match repo.delete_by_medication_id(&id).await { Ok(true) => Ok(StatusCode::NO_CONTENT), - 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" })), - )) - } + Ok(false) => Err(StatusCode::NOT_FOUND), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } @@ -259,34 +136,8 @@ pub async fn log_dose( Extension(claims): Extension, Path(id): Path, Json(req): Json, -) -> Result<(StatusCode, Json), (StatusCode, Json)> { +) -> Result<(StatusCode, Json), StatusCode> { 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(); @@ -304,13 +155,7 @@ pub async fn log_dose( .collection::("medication_doses") .insert_one(&dose, None) .await - .map_err(|e| { - tracing::error!("log_dose insert failed: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": "database error" })), - ) - })?; + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // Populate the generated _id so the caller gets the persisted dose back. dose.id = result.inserted_id.as_object_id(); @@ -320,10 +165,9 @@ pub async fn log_dose( pub async fn get_adherence( State(state): State, - Extension(claims): Extension, + Extension(_claims): Extension, Path(id): Path, -) -> Result, (StatusCode, Json)> -{ +) -> Result, StatusCode> { use mongodb::bson::{doc, DateTime}; const PERIOD_DAYS: i64 = 30; @@ -332,28 +176,6 @@ 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), @@ -366,25 +188,19 @@ pub async fn get_adherence( let total_logged = doses .count_documents(filter.clone(), None) .await - .map_err(|e| { - tracing::error!("get_adherence count failed: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": "database error" })), - ) - })?; + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let taken_filter = doc! { "$and": [filter, doc! { "taken": true }] }; let taken = doses .count_documents(taken_filter, None) .await - .map_err(|e| { - tracing::error!("get_adherence taken count failed: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": "database error" })), - ) - })?; + .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)?; 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 68ac996..521d0b1 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -4,9 +4,10 @@ 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 @@ -23,12 +24,10 @@ pub use medications::{ create_medication, delete_medication, get_adherence, get_medication, list_medications, log_dose, update_medication, }; -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 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_account, get_settings, update_account, update_settings, + change_password, delete_account, get_profile, get_settings, update_profile, update_settings, }; diff --git a/backend/src/handlers/permissions.rs b/backend/src/handlers/permissions.rs new file mode 100644 index 0000000..6a4c751 --- /dev/null +++ b/backend/src/handlers/permissions.rs @@ -0,0 +1,62 @@ +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 c3edb21..ea3d37e 100644 --- a/backend/src/handlers/profile.rs +++ b/backend/src/handlers/profile.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Extension, Path, State}, + extract::{Extension, State}, http::StatusCode, Json, }; @@ -12,21 +12,17 @@ use crate::{ models::profile::{Profile, ProfileRepository}, }; -/// The profile as exposed to clients. Display name + wrapped profile DEK are -/// returned as opaque client-encrypted blobs (the server cannot read either). +/// The profile as exposed to clients. The display name is returned as an opaque +/// client-encrypted blob (the server cannot read it). #[derive(Debug, Serialize)] pub struct ProfileResponse { pub profile_id: String, - pub owner_account_id: String, + pub user_id: String, + /// Opaque client-encrypted display name. 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, } @@ -35,212 +31,98 @@ impl From for ProfileResponse { fn from(p: Profile) -> Self { Self { profile_id: p.profile_id, - owner_account_id: p.owner_account_id, + user_id: p.user_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, } } } -/// 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 { +/// 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 { let now = DateTime::now(); Profile { id: None, - // 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(), + profile_id: format!("profile_{user_id}"), + user_id: user_id.to_string(), family_id: None, - 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(), + name: String::new(), + name_iv: String::new(), + name_auth_tag: String::new(), 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, } } -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. -// --------------------------------------------------------------------------- - +/// PUT /profiles/me body — the client sends the encrypted name blob. #[derive(Debug, Deserialize)] -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 struct UpdateProfileNameRequest { 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, } -fn default_kind_value() -> String { - "human".to_string() -} - -pub async fn create_profile( +/// 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( State(state): State, Extension(claims): Extension, - 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, - }; - - 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)))) -} - -// --------------------------------------------------------------------------- -// GET /api/profiles/:id — fetch one owned profile -// --------------------------------------------------------------------------- - -pub async fn get_profile( - State(state): State, - Extension(claims): Extension, - Path(profile_id): Path, ) -> Result, (StatusCode, Json)> { - let repo = profile_repo(&state); - match repo - .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" })), - )), + 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); - Err(( + return Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "database error" })), - )) + )); } - } + }; + + Ok(Json(ProfileResponse::from(profile))) } -// --------------------------------------------------------------------------- -// 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( +/// PUT /api/profiles/me — update the profile's encrypted display-name blob. +pub async fn update_my_profile( State(state): State, Extension(claims): Extension, - Path(profile_id): Path, - Json(req): Json, + Json(req): Json, ) -> Result, (StatusCode, Json)> { - let repo = profile_repo(&state); + let database = state.db.get_database(); + let repo = ProfileRepository::new(database.collection("profiles")); + match repo - .update_profile( - &profile_id, + .update_encrypted_name( &claims.sub, &req.name_data, &req.name_iv, &req.name_auth_tag, - &req.kind, - &req.relationship, ) .await { @@ -258,29 +140,3 @@ pub async fn update_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 deleted file mode 100644 index 3895fb1..0000000 --- a/backend/src/handlers/profile_share.rs +++ /dev/null @@ -1,708 +0,0 @@ -//! 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 new file mode 100644 index 0000000..4481f96 --- /dev/null +++ b/backend/src/handlers/shares.rs @@ -0,0 +1,453 @@ +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 9b3ea33..0dc0660 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_account( +pub async fn get_profile( State(state): State, Extension(claims): Extension, ) -> impl IntoResponse { @@ -79,7 +79,7 @@ pub async fn get_account( } } -pub async fn update_account( +pub async fn update_profile( State(state): State, Extension(claims): Extension, Json(req): Json, diff --git a/backend/src/middleware/permission.rs b/backend/src/middleware/permission.rs new file mode 100644 index 0000000..d265054 --- /dev/null +++ b/backend/src/middleware/permission.rs @@ -0,0 +1,96 @@ +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 8ba2140..d6d5efb 100644 --- a/backend/src/models/appointment.rs +++ b/backend/src/models/appointment.rs @@ -126,21 +126,16 @@ impl AppointmentRepository { Ok(appointment) } - /// List a user's appointments, optionally filtered by top-level `status` - /// and/or `profileId`. + /// List a user's appointments, optionally filtered by top-level `status`. 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 4b11669..5f28485 100644 --- a/backend/src/models/health_stats.rs +++ b/backend/src/models/health_stats.rs @@ -15,9 +15,6 @@ 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, @@ -30,7 +27,6 @@ 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, } @@ -40,7 +36,6 @@ 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, } @@ -75,21 +70,6 @@ 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 175d86d..bc326e8 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -6,8 +6,9 @@ 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 new file mode 100644 index 0000000..db0513c --- /dev/null +++ b/backend/src/models/permission.rs @@ -0,0 +1,44 @@ +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 4852243..3881064 100644 --- a/backend/src/models/profile.rs +++ b/backend/src/models/profile.rs @@ -1,76 +1,35 @@ -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, - /// "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")] + #[serde(rename = "role")] pub role: String, - #[serde(rename = "permissions", default)] + #[serde(rename = "permissions")] 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, } @@ -85,7 +44,6 @@ impl ProfileRepository { Ok(()) } - /// Look up a profile by its application-level profile id. pub async fn find_by_profile_id( &self, profile_id: &str, @@ -95,102 +53,32 @@ impl ProfileRepository { .await } - /// 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> { + /// Look up a profile by its owning user id. + pub async fn find_by_user_id(&self, user_id: &str) -> mongodb::error::Result> { self.collection - .find_one( - doc! { "profileId": profile_id, "ownerAccountId": owner }, - None, - ) + .find_one(doc! { "userId": user_id }, None) .await } - /// 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( + /// Update the profile's display name (opaque client-encrypted blob). + pub async fn update_encrypted_name( &self, - profile_id: &str, - owner: &str, + user_id: &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! { "profileId": profile_id, "ownerAccountId": owner }, + doc! { "userId": user_id }, 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 deleted file mode 100644 index 1fc50fd..0000000 --- a/backend/src/models/profile_share.rs +++ /dev/null @@ -1,222 +0,0 @@ -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 new file mode 100644 index 0000000..2a9df80 --- /dev/null +++ b/backend/src/models/share.rs @@ -0,0 +1,123 @@ +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 bbb0eb0..74de914 100644 --- a/backend/src/models/user.rs +++ b/backend/src/models/user.rs @@ -37,21 +37,6 @@ 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, @@ -108,9 +93,6 @@ 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 ce7b24f..3da535a 100644 --- a/backend/tests/auth_tests.rs +++ b/backend/tests/auth_tests.rs @@ -287,80 +287,6 @@ 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 deleted file mode 100644 index b5cc3b9..0000000 --- a/backend/tests/ownership_tests.rs +++ /dev/null @@ -1,258 +0,0 @@ -//! 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 deleted file mode 100644 index 2a9a470..0000000 --- a/backend/tests/profile_tests.rs +++ /dev/null @@ -1,255 +0,0 @@ -//! 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 deleted file mode 100644 index 5bb4893..0000000 --- a/backend/tests/rekey_tests.rs +++ /dev/null @@ -1,382 +0,0 @@ -//! Hard-revoke / re-key integration tests (Phase C). -//! -//! Verifies POST /api/profiles/:id/rekey: rotating the profile's wrapped DEK, -//! upserting submitted recipient envelopes (fresh ECDH wraps), and hard- -//! deleting any current recipient NOT in the submitted list. Wire-level only -//! (opaque blobs — the server never inspects them, so the tests don't need -//! real crypto). -//! -//! Requires a live MongoDB; skips gracefully otherwise. - -mod common; - -use serde_json::{json, Value}; - -macro_rules! require_app { - ($app:expr) => { - match $app { - Some(x) => x, - None => { - eprintln!("[integration] skipped (MongoDB unavailable)"); - return; - } - } - }; -} - -fn unique_email() -> String { - format!("test_{}@example.com", uuid::Uuid::new_v4()) -} - -/// Register a fully-set-up user (identity key + default self profile). -async fn register_full(app: &axum::Router, email: &str) -> Value { - let (status, body) = common::send_json( - app, - "POST", - "/api/auth/register", - Some(json!({ - "email": email, - "username": email, - "password": "supersecret", - "identity_public_key": format!("pub-{email}"), - "default_profile_name_data": "n", - "default_profile_name_iv": "i", - "default_wrapped_profile_dek": "owner-dek-v1", - "default_wrapped_profile_dek_iv": "owner-dek-iv-v1", - })), - None, - ) - .await; - assert_eq!(status, 201, "register_full failed, body: {body}"); - body -} - -fn token(body: &Value) -> String { - body["token"].as_str().unwrap().to_string() -} - -fn self_profile_id(body: &Value) -> String { - format!("profile_{}", body["user_id"].as_str().unwrap()) -} - -/// Owner shares profile to recipient_email; returns the recipient's user_id -/// (looked up from the share listing afterward). -async fn share_to( - app: &axum::Router, - owner_token: &str, - profile_id: &str, - recipient_email: &str, -) -> String { - let (status, _) = common::send_json( - app, - "POST", - &format!("/api/profiles/{profile_id}/shares"), - Some(json!({ - "recipient_email": recipient_email, - "ephemeral_public_key": format!("eph-{recipient_email}"), - "wrapped_profile_dek": format!("wrap-{recipient_email}"), - "wrapped_profile_dek_iv": "iv", - "permissions": ["read"], - })), - Some(owner_token), - ) - .await; - assert_eq!(status, 201, "share_to failed"); - // Look up the recipient's user_id from the listing. - let (_, listing) = common::send_json( - app, - "GET", - &format!("/api/profiles/{profile_id}/shares"), - None, - Some(owner_token), - ) - .await; - listing - .as_array() - .unwrap() - .iter() - .find(|s| s["recipient_email"] == recipient_email) - .unwrap()["recipient_user_id"] - .as_str() - .unwrap() - .to_string() -} - -#[tokio::test] -async fn rekey_rotates_owner_share_and_keeps_listed_recipients() { - let (app, db_name) = require_app!(common::app_for_test().await); - let owner_body = register_full(&app, &unique_email()).await; - let owner_token = token(&owner_body); - let profile_id = self_profile_id(&owner_body); - - let recipient_b = unique_email(); - let recipient_c = unique_email(); - register_full(&app, &recipient_b).await; - register_full(&app, &recipient_c).await; - let b_uid = share_to(&app, &owner_token, &profile_id, &recipient_b).await; - let _c_uid = share_to(&app, &owner_token, &profile_id, &recipient_c).await; - - // Owner rekeys: keeps B (fresh envelope), omits C (hard-revoke). - let (status, body) = common::send_json( - &app, - "POST", - &format!("/api/profiles/{profile_id}/rekey"), - Some(json!({ - "new_wrapped_profile_dek": "owner-dek-v2", - "new_wrapped_profile_dek_iv": "owner-dek-iv-v2", - "recipient_envelopes": [{ - "recipient_user_id": b_uid, - "ephemeral_public_key": "eph-b-v2", - "wrapped_profile_dek": "wrap-b-v2", - "wrapped_profile_dek_iv": "iv-v2", - }], - })), - Some(&owner_token), - ) - .await; - assert_eq!(status, 200, "rekey should return 200, body: {body}"); - // Owner share rotated. - assert_eq!(body["wrapped_profile_dek"], "owner-dek-v2"); - assert_eq!(body["wrapped_profile_dek_iv"], "owner-dek-iv-v2"); - // Retained = [B]. - let retained: Vec<&str> = body["retained_recipient_user_ids"] - .as_array() - .unwrap() - .iter() - .map(|v| v.as_str().unwrap()) - .collect(); - assert_eq!(retained, vec![b_uid.as_str()]); - - // The profile's stored owner-share reflects v2. - let (_, profile_body) = common::send_json( - &app, - "GET", - &format!("/api/profiles/{profile_id}"), - None, - Some(&owner_token), - ) - .await; - assert_eq!(profile_body["wrapped_profile_dek"], "owner-dek-v2"); - - common::drop_test_db(&db_name).await; -} - -#[tokio::test] -async fn rekey_hard_revokes_omitted_recipient() { - let (app, db_name) = require_app!(common::app_for_test().await); - let owner_body = register_full(&app, &unique_email()).await; - let owner_token = token(&owner_body); - let profile_id = self_profile_id(&owner_body); - - let recipient_b = unique_email(); - let recipient_c = unique_email(); - let b_body = register_full(&app, &recipient_b).await; - let c_body = register_full(&app, &recipient_c).await; - let b_token = token(&b_body); - let c_token = token(&c_body); - let b_uid = share_to(&app, &owner_token, &profile_id, &recipient_b).await; - let _c_uid = share_to(&app, &owner_token, &profile_id, &recipient_c).await; - - // Before rekey: both recipients see the profile in shared-with-me. - let (_, shared_before) = common::send_json( - &app, - "GET", - "/api/profiles/shared-with-me", - None, - Some(&c_token), - ) - .await; - assert_eq!( - shared_before.as_array().unwrap().len(), - 1, - "C should see the shared profile before rekey" - ); - - // Rekey keeping only B. - let (status, _) = common::send_json( - &app, - "POST", - &format!("/api/profiles/{profile_id}/rekey"), - Some(json!({ - "new_wrapped_profile_dek": "owner-dek-v2", - "new_wrapped_profile_dek_iv": "owner-dek-iv-v2", - "recipient_envelopes": [{ - "recipient_user_id": b_uid, - "ephemeral_public_key": "eph-b-v2", - "wrapped_profile_dek": "wrap-b-v2", - "wrapped_profile_dek_iv": "iv-v2", - }], - })), - Some(&owner_token), - ) - .await; - assert_eq!(status, 200); - - // B (kept) still sees it, with the rotated envelope. - let (_, shared_b) = common::send_json( - &app, - "GET", - "/api/profiles/shared-with-me", - None, - Some(&b_token), - ) - .await; - let arr_b = shared_b.as_array().unwrap(); - assert_eq!(arr_b.len(), 1, "B sees exactly the one shared profile"); - assert_eq!( - arr_b[0]["wrapped_profile_dek"], "wrap-b-v2", - "B's envelope rotated" - ); - - // C (omitted) no longer sees it. - let (_, shared_c) = common::send_json( - &app, - "GET", - "/api/profiles/shared-with-me", - None, - Some(&c_token), - ) - .await; - assert_eq!( - shared_c.as_array().unwrap().len(), - 0, - "C must be hard-revoked from shared-with-me" - ); - // And the share-gate 404s C on direct read. - let (status, _) = common::send_json( - &app, - "GET", - &format!("/api/profiles/{profile_id}"), - None, - Some(&c_token), - ) - .await; - assert_eq!( - status, 404, - "C must be denied by the gate after hard revoke" - ); - - common::drop_test_db(&db_name).await; -} - -#[tokio::test] -async fn rekey_rejects_envelope_for_recipient_with_no_share() { - let (app, db_name) = require_app!(common::app_for_test().await); - let owner_body = register_full(&app, &unique_email()).await; - let owner_token = token(&owner_body); - let profile_id = self_profile_id(&owner_body); - - // A recipient who was never shared with. - let stranger_body = register_full(&app, &unique_email()).await; - let stranger_uid = stranger_body["user_id"].as_str().unwrap().to_string(); - - let (status, body) = common::send_json( - &app, - "POST", - &format!("/api/profiles/{profile_id}/rekey"), - Some(json!({ - "new_wrapped_profile_dek": "owner-dek-v2", - "new_wrapped_profile_dek_iv": "owner-dek-iv-v2", - "recipient_envelopes": [{ - "recipient_user_id": stranger_uid, - "ephemeral_public_key": "x", - "wrapped_profile_dek": "y", - "wrapped_profile_dek_iv": "z", - }], - })), - Some(&owner_token), - ) - .await; - assert_eq!( - status, 400, - "envelope for a recipient with no share must be rejected: {body}" - ); - - common::drop_test_db(&db_name).await; -} - -#[tokio::test] -async fn rekey_rejects_non_owner() { - let (app, db_name) = require_app!(common::app_for_test().await); - let owner_body = register_full(&app, &unique_email()).await; - let profile_id = self_profile_id(&owner_body); - - let other_token = token(®ister_full(&app, &unique_email()).await); - - let (status, _) = common::send_json( - &app, - "POST", - &format!("/api/profiles/{profile_id}/rekey"), - Some(json!({ - "new_wrapped_profile_dek": "x", - "new_wrapped_profile_dek_iv": "y", - "recipient_envelopes": [], - })), - Some(&other_token), - ) - .await; - assert_eq!(status, 404, "non-owner rekey must be rejected"); - - // Profile is unchanged. - let (_, profile_body) = common::send_json( - &app, - "GET", - &format!("/api/profiles/{profile_id}"), - None, - Some(token(&owner_body).as_str()), - ) - .await; - assert_eq!(profile_body["wrapped_profile_dek"], "owner-dek-v1"); - - common::drop_test_db(&db_name).await; -} - -#[tokio::test] -async fn rekey_with_empty_envelopes_revokes_all_recipients() { - // Keeping nobody = hard-revoke everyone (e.g. suspected compromise). - let (app, db_name) = require_app!(common::app_for_test().await); - let owner_body = register_full(&app, &unique_email()).await; - let owner_token = token(&owner_body); - let profile_id = self_profile_id(&owner_body); - - let recipient_b = unique_email(); - register_full(&app, &recipient_b).await; - share_to(&app, &owner_token, &profile_id, &recipient_b).await; - - let (status, body) = common::send_json( - &app, - "POST", - &format!("/api/profiles/{profile_id}/rekey"), - Some(json!({ - "new_wrapped_profile_dek": "owner-dek-v2", - "new_wrapped_profile_dek_iv": "owner-dek-iv-v2", - "recipient_envelopes": [], - })), - Some(&owner_token), - ) - .await; - assert_eq!( - status, 200, - "rekey with no envelopes should succeed: {body}" - ); - assert_eq!( - body["retained_recipient_user_ids"] - .as_array() - .unwrap() - .len(), - 0 - ); - - // The shares listing is now empty. - let (_, listing) = common::send_json( - &app, - "GET", - &format!("/api/profiles/{profile_id}/shares"), - None, - Some(&owner_token), - ) - .await; - assert_eq!(listing.as_array().unwrap().len(), 0); - - common::drop_test_db(&db_name).await; -} diff --git a/backend/tests/share_tests.rs b/backend/tests/share_tests.rs deleted file mode 100644 index 05d84a1..0000000 --- a/backend/tests/share_tests.rs +++ /dev/null @@ -1,456 +0,0 @@ -//! 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 50c3c65..1e38c41 100644 --- a/backend/tests/zk_integration_tests.rs +++ b/backend/tests/zk_integration_tests.rs @@ -179,7 +179,6 @@ 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 1759ef1..b8b4e9f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -11,15 +11,14 @@ 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, `token_version` revocation, refresh rotation — *reconciled with code 2026-07-18* | +| [jwt-authentication-decision.md](./jwt-authentication-decision.md) | JWT access/refresh tokens, recovery phrases | | [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, 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) | +| [zero-knowledge-encryption.md](./zero-knowledge-encryption.md) | Client-side zero-knowledge encryption (AES-256-GCM, double-PBKDF2, Phase 1) | > **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 8af389d..bf3f59b 100644 --- a/docs/adr/jwt-authentication-decision.md +++ b/docs/adr/jwt-authentication-decision.md @@ -1,185 +1,174 @@ -# ADR: JWT Authentication +# JWT Authentication Decision Summary -**Status**: Implemented (current code is the source of truth) -**Date**: 2026-02-14 (original decision), 2026-07-18 (reconciled with code) +**Date**: 2026-02-14 +**Decision**: **JWT with Refresh Tokens + Recovery Phrases** -> 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**. +--- -## Context +## Authentication Strategy -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. +### Primary: JWT (JSON Web Tokens) -## Decision +**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 -### Token model +### Token Types -Two JWTs, both HS256-signed with a shared secret from config: +**Access Token** (15 minutes) +- Used for API requests +- Short-lived for security +- Contains: user_id, email, family_id, permissions -| 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 | +**Refresh Token** (30 days) +- Used to get new access tokens +- Long-lived for convenience +- Stored in MongoDB for revocation +- Rotated on every refresh -> Reference: `backend/src/auth/jwt.rs` (`Claims`, `RefreshClaims`, `JwtService`). +--- -The lifetimes are **config-driven**, not hardcoded — the original ADR's "15 min -/ 30 days" are the defaults, not constants. +## Token Revocation Strategies -### Claims (actual, as implemented) +### 1. Refresh Token Blacklist (Recommended) ⭐ +- Store refresh tokens in MongoDB +- Mark as revoked on logout +- Check on every refresh -The earlier ADR listed richer claims (`family_id`, `permissions`, `token_type`). -**These are not in the shipped access token.** The real `Claims` struct is: +### 2. Token Versioning +- Include version in JWT claims +- Increment on password change +- Invalidate all tokens when version changes -```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, -} +### 3. Access Token Blacklist (Optional) +- Store revoked access tokens in Redis +- For immediate revocation +- Auto-expires with TTL -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, +--- + +## 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" + ] } ``` -Note what is **absent** and why: +### Permission Middleware -- **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. +- Check permissions on protected routes +- Return 403 Forbidden if insufficient permissions +- Works with JWT claims -### Refresh tokens: persistence, rotation, reuse detection +--- -- 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). +## Technology Stack -### Token revocation: versioning + session management +### Backend (Axum) +- jsonwebtoken 9.x (JWT crate) +- bcrypt 0.15 (password hashing) +- mongodb 3.0 (refresh token storage) +- redis (optional, for access token blacklist) -Two mechanisms, both implemented: +### Client (React Native + React) +- AsyncStorage (token storage) +- axios (API client with JWT interceptor) +- PBKDF2 (password derivation) +- AES-256-GCM (data encryption) -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. +--- -### Password handling +## Implementation Timeline -- **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. +- **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) -### Password recovery +**Total**: 3-4 weeks -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. +--- -### What protects what +## Next Steps -Auth + the ciphertext store are defended server-side; plaintext health data is -never on the server to protect: +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) -| 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 -- 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) +- [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) diff --git a/docs/adr/multi-person-sharing.md b/docs/adr/multi-person-sharing.md deleted file mode 100644 index e2e1825..0000000 --- a/docs/adr/multi-person-sharing.md +++ /dev/null @@ -1,336 +0,0 @@ -# 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/archive/ENCRYPTION_UPDATE_SUMMARY.md b/docs/product/ENCRYPTION_UPDATE_SUMMARY.md similarity index 85% rename from docs/archive/ENCRYPTION_UPDATE_SUMMARY.md rename to docs/product/ENCRYPTION_UPDATE_SUMMARY.md index 68df3f4..2d745ea 100644 --- a/docs/archive/ENCRYPTION_UPDATE_SUMMARY.md +++ b/docs/product/ENCRYPTION_UPDATE_SUMMARY.md @@ -1,13 +1,5 @@ # 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 e1131f8..5a99c39 100644 --- a/docs/product/PERSONA_AND_FAMILY_MANAGEMENT.md +++ b/docs/product/PERSONA_AND_FAMILY_MANAGEMENT.md @@ -1,16 +1,10 @@ # Persona and Family Management - Product Definition -**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 +**Document Version**: 1.0 +**Date**: 2026-03-09 +**Status**: Draft - For Refinement **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 @@ -41,28 +35,6 @@ 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 @@ -195,12 +167,7 @@ User Account: jane.doe@example.com (Jane) --- -## 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. +## Current Implementation Status ### ✅ Implemented (Backend) @@ -628,23 +595,16 @@ if profile.role == "child" || profile.age < 18 { ### Encryption -> **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). +**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 -**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 +**Encrypted Fields**: +- `profile.name` - Person's name +- `family.name` - Family group name +- Medication data (when implemented) --- diff --git a/docs/product/encryption.md b/docs/product/encryption.md index a7e30ab..55302f6 100644 --- a/docs/product/encryption.md +++ b/docs/product/encryption.md @@ -1,257 +1,906 @@ -# Zero-Knowledge Encryption +# Zero-Knowledge Encryption Implementation Guide -**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/`. +## 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) 🆕 --- -## 1. The core property +## 🚨 Implementation Status -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: +**Last Updated**: 2026-03-09 -- 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. +### 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 -This is the feature that differentiates Normogen from commercial health -platforms: even the operator of the server cannot read your data. +### 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. --- -## 2. Key model (wrapped-DEK) +## Proton-Style Encryption for MongoDB -Two layers of keys: +### Architecture Overview ``` - ┌─── 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) +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 ``` -- **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. +### Implementation Approaches -### Key derivation parameters +#### 1. Application-Level Encryption (Recommended) -| 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`. +Encrypt sensitive fields before they reach MongoDB. --- -## 3. Lifecycle +## Rust Implementation Examples 🆕 -### Registration +### Current Security Implementation -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. +Normogen currently implements the following security features in Rust: -### Login (unlock) +#### 1. JWT Authentication Service -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`). +**File**: `backend/src/auth/mod.rs` -### Password change +```rust +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use chrono::{Duration, Utc}; -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. +#[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" +} -### Password recovery (via recovery phrase) +pub struct AuthService { + jwt_secret: String, +} -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). +impl AuthService { + pub fn new(jwt_secret: String) -> Self { + Self { jwt_secret } + } -This is the hard problem in zero-knowledge systems and it is solved here -without the server ever touching an unwrapped key. + /// 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(), + }; -## 4. What is encrypted vs. plaintext + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(self.jwt_secret.as_ref()), + ) + .map_err(|e| Error::TokenCreation(e.to_string())) + } -The split is deliberate: enough stays plaintext for the server to query and -aggregate (filtering, adherence math); everything sensitive is ciphertext. + /// 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; -### Encrypted (client-side, under the DEK) + let claims = Claims { + sub: user_id.to_owned(), + exp: expiration, + iat: Utc::now().timestamp() as usize, + token_type: "refresh".to_string(), + }; -- 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 + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(self.jwt_secret.as_ref()), + ) + .map_err(|e| Error::TokenCreation(e.to_string())) + } -### Plaintext (server-visible, queryable) - -- 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 - -### Wire shape - -``` -EncryptedField { data: base64, iv: base64, auth_tag: base64 } + /// 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())) + } +} ``` -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. +#### 2. Password Hashing with PBKDF2 -> Reference: `web/normogen-web/src/crypto/cipher.ts`, `backend/src/models/medication.rs` (`EncryptedFieldWire`). +**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 + } +} +``` --- -## 5. In-memory key lifecycle (the ZK trade-off) +## Future Zero-Knowledge Encryption Design -The DEK lives only in browser memory for the authenticated session. It is -**not persisted** to `localStorage`, `sessionStorage`, or a cookie. +### Proposed Implementation for Health Data -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. +The following sections describe how to implement zero-knowledge encryption for sensitive health data. This is currently **not implemented** in Normogen. -On logout / tab close, the key is cleared via `clearEncKey()`. +#### 1. Encryption Service Design + +```rust +use aes_gcm::{ + aead::{Aead, AeadCore, KeyInit, OsRng}, + Aes256Gcm, Nonce, +}; +use rand::RngCore; + +pub struct EncryptionService { + cipher: Aes256Gcm, +} + +impl EncryptionService { + /// Create new encryption service with key + pub fn new(key: &[u8; 32]) -> Self { + let cipher = Aes256Gcm::new(key.into()); + Self { cipher } + } + + /// 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 + } +} +``` + +#### 2. Encrypted Health Data Model + +```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())) + } +} +``` --- -## 6. What is NOT yet solved (see issues) +## Key Management Strategy -### Multi-person / family / caregiver sharing — unsolved +### Environment Variables -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. +```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 +``` -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)**. +### Key Derivation -### Multi-profile within one account — partial +```rust +use sha2::{Sha256, Digest}; -`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 KeyManager { + master_key: [u8; 32], +} -### Key rotation / re-encryption +impl KeyManager { + pub fn new(master_key: [u8; 32]) -> Self { + Self { master_key } + } -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. + /// 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() + } +} +``` --- -## 7. Backend security (complementary, server-side) +## Shareable Links with Embedded Passwords -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). +### Architecture Overview -| 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 | +``` +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 +``` -> Reference: `backend/src/auth/`, `backend/src/security/`, `backend/src/models/audit_log.rs`. +### Implementation Design + +```rust +use rand::RngCore; + +#[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, +} + +pub struct ShareService { + encryption_service: EncryptionService, +} + +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() +} +``` --- -## 8. Threat model summary +## Password Recovery in Zero-Knowledge Systems -| 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 | +### Recovery with Zero-Knowledge Phrases + +Currently implemented in Normogen: + +```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 + } +} +``` --- -## References +## Security Best Practices -- 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` +### 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 + +--- + +## Comparison: Current vs Proposed + +### 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 diff --git a/web/normogen-web/src/components/appointments/AppointmentsManager.tsx b/web/normogen-web/src/components/appointments/AppointmentsManager.tsx index ed6d8cf..d3ee7b0 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, useProfileStore } from '../../store/useStore'; +import { useAppointmentStore, useAuthStore } 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 activeProfileId = useProfileStore((s) => s.activeProfileId); + const user = useAuthStore((s) => s.user); const [createOpen, setCreateOpen] = useState(false); const [createForm, setCreateForm] = useState(emptyCreate); @@ -84,13 +84,13 @@ export const AppointmentsManager: FC = () => { const [saving, setSaving] = useState(false); useEffect(() => { - if (activeProfileId) loadAppointments(); - }, [loadAppointments, activeProfileId]); + loadAppointments(); + }, [loadAppointments]); const openCreate = () => { setCreateForm({ ...emptyCreate, - profile_id: activeProfileId ?? 'default', + profile_id: `profile_${user?.user_id ?? '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 93c5d11..b70c813 100644 --- a/web/normogen-web/src/components/medication/MedicationManager.test.tsx +++ b/web/normogen-web/src/components/medication/MedicationManager.test.tsx @@ -38,8 +38,6 @@ 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 fcdfc09..e80679c 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, useProfileStore } from '../../store/useStore'; +import { useMedicationStore, useAuthStore } 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 activeProfileId = useProfileStore((s) => s.activeProfileId); + const user = useAuthStore((s) => s.user); const [createOpen, setCreateOpen] = useState(false); const [createForm, setCreateForm] = useState(emptyCreate); @@ -69,16 +69,17 @@ 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(() => { - if (activeProfileId) loadMedications(); - }, [loadMedications, activeProfileId]); + loadMedications(); + }, [loadMedications]); 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: activeProfileId ?? 'default', + profile_id: profileId, }); setCreateOpen(true); }; diff --git a/web/normogen-web/src/components/profile/ProfileEditor.tsx b/web/normogen-web/src/components/profile/ProfileEditor.tsx index d070990..7f258f5 100644 --- a/web/normogen-web/src/components/profile/ProfileEditor.tsx +++ b/web/normogen-web/src/components/profile/ProfileEditor.tsx @@ -7,62 +7,34 @@ 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 { - profiles, - activeProfileId, - isLoading, - error, - loadProfiles, - updateProfile, - createProfile, - deleteProfile, - clearError, - } = useProfileStore(); + const { profile, isLoading, error, loadProfile, updateName, 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); - // Create-profile dialog state - const [creating, setCreating] = useState(false); - const [newName, setNewName] = useState(''); - const [newKind, setNewKind] = useState('human'); - const [newRel, setNewRel] = useState(''); + useEffect(() => { + loadProfile(); + }, [loadProfile]); useEffect(() => { - loadProfiles(); - }, [loadProfiles]); - - useEffect(() => { - if (active) { - setName(active.name); - setKind(active.kind || 'human'); - setRelationship(active.relationship || ''); - } - }, [active]); + if (profile?.name) setName(profile.name); + }, [profile]); const handleSave = async () => { - if (!active) return; setSaving(true); try { - await updateProfile(active.profile_id, { name, kind, relationship }); + await updateName(name); setEditing(false); } catch { /* error surfaced via store */ @@ -71,43 +43,11 @@ 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 ( - - Profiles - - + + Profile + {error && ( @@ -115,11 +55,11 @@ export const ProfileEditor: FC = () => { )} - {isLoading && profiles.length === 0 ? ( + {isLoading && !profile ? ( - ) : active ? ( + ) : ( @@ -144,154 +84,39 @@ export const ProfileEditor: FC = () => { > {saving ? : 'Save'} - ) : ( - {active.name || user?.username || '—'} - {active.is_shared ? ( - - ) : ( - - )} + {profile?.name ?? user?.username ?? '—'} + )} - {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 }} - /> - - - )} + + + Account + + {user?.email} + - {!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 && ( - - + + + Role + + + - )} - - - - ) : ( - - 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, ..." - /> - - - - + {profile?.profile_id && ( + + Profile ID: {profile.profile_id} + + )} diff --git a/web/normogen-web/src/components/profile/ProfileSharing.tsx b/web/normogen-web/src/components/profile/ProfileSharing.tsx deleted file mode 100644 index 54497dd..0000000 --- a/web/normogen-web/src/components/profile/ProfileSharing.tsx +++ /dev/null @@ -1,189 +0,0 @@ -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 deleted file mode 100644 index fa1c00a..0000000 --- a/web/normogen-web/src/components/profile/ProfileSwitcher.tsx +++ /dev/null @@ -1,66 +0,0 @@ -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 4430b44..b160193 100644 --- a/web/normogen-web/src/crypto/crypto.e2e.test.ts +++ b/web/normogen-web/src/crypto/crypto.e2e.test.ts @@ -6,43 +6,11 @@ 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'; @@ -131,205 +99,3 @@ 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 798cec2..16ec69f 100644 --- a/web/normogen-web/src/crypto/index.ts +++ b/web/normogen-web/src/crypto/index.ts @@ -8,29 +8,10 @@ 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 b96d09d..25577a5 100644 --- a/web/normogen-web/src/crypto/keys.ts +++ b/web/normogen-web/src/crypto/keys.ts @@ -114,130 +114,6 @@ 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 // --------------------------------------------------------------------------- @@ -309,62 +185,6 @@ 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) // --------------------------------------------------------------------------- @@ -387,67 +207,6 @@ 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 4f22b01..dfabf52 100644 --- a/web/normogen-web/src/pages/Dashboard.tsx +++ b/web/normogen-web/src/pages/Dashboard.tsx @@ -6,7 +6,6 @@ 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; @@ -36,7 +35,6 @@ 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 8e3b0a3..2752138 100644 --- a/web/normogen-web/src/pages/UnlockPage.tsx +++ b/web/normogen-web/src/pages/UnlockPage.tsx @@ -12,23 +12,11 @@ import { } from '@mui/material'; import { Lock as LockIcon } from '@mui/icons-material'; import { useAuthStore } from '../store/useStore'; -import { - unlockWithPassword, - deriveAuthAndEncKeys, - unwrapIdentityPrivateKey, - setEncKey, - setIdentityPrivate, -} from '../crypto'; +import { unlockWithPassword, deriveAuthAndEncKeys, setEncKey } from '../crypto'; export const UnlockPage: FC = () => { const navigate = useNavigate(); - const { - wrapped_dek, - wrapped_dek_iv, - identity_private_key_wrapped, - identity_private_key_wrapped_iv, - user, - } = useAuthStore(); + const { wrapped_dek, wrapped_dek_iv, user } = useAuthStore(); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [unlocking, setUnlocking] = useState(false); @@ -39,32 +27,13 @@ export const UnlockPage: FC = () => { setUnlocking(true); try { - let dek; if (wrapped_dek && wrapped_dek_iv) { // Wrapped-DEK model: unwrap the DEK using the password. - ({ dek } = await unlockWithPassword(password, { + const { 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 12c5bc5..344586c 100644 --- a/web/normogen-web/src/services/api.ts +++ b/web/normogen-web/src/services/api.ts @@ -18,14 +18,6 @@ import { MedicationWireResponse, AppointmentWireResponse, ProfileWireResponse, - CreateProfileRequest, - UpdateProfileRequest, - SharedProfileWireResponse, - CreateProfileShareRequest, - ProfileShareListing, - PublicKeyResponse, - RekeyRequest, - RekeyResponse, HealthStatWireResponse, UpdateHealthStatRequest, EncryptedFieldWire, @@ -240,93 +232,25 @@ class ApiService { }); } - // ---- Profiles (Phase A2: multi-profile, per-profile DEKs) ---- + // ---- Profile (zero-knowledge: opaque encrypted name) ---- - async listProfiles(): Promise { - const response = await this.client.get('/profiles'); + async getProfile(): Promise { + const response = await this.client.get('/profiles/me'); return response.data; } - 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 }, + async updateProfileName(nameData: string, nameIv: string): Promise { + const response = await this.client.put('/profiles/me', { + name_data: nameData, + name_iv: nameIv, }); 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(profileId?: string): Promise { - const params = profileId ? { profile_id: profileId } : undefined; - const response = await this.client.get('/medications', { params }); + async getMedications(): Promise { + const response = await this.client.get('/medications'); return response.data; } @@ -363,12 +287,9 @@ class ApiService { // ---- Appointments (zero-knowledge: opaque encrypted blobs) ---- - async getAppointments(status?: string, profileId?: string): Promise { - const params: Record = {}; - if (status) params.status = status; - if (profileId) params.profile_id = profileId; + async getAppointments(status?: string): Promise { const response = await this.client.get('/appointments', { - params: Object.keys(params).length ? params : undefined, + params: status ? { status } : undefined, }); return response.data; } @@ -411,9 +332,8 @@ class ApiService { // ---- Health Statistics (zero-knowledge: opaque encrypted blobs) ---- - async getHealthStats(profileId?: string): Promise { - const params = profileId ? { profile_id: profileId } : undefined; - const response = await this.client.get('/health-stats', { params }); + async getHealthStats(): Promise { + const response = await this.client.get('/health-stats'); return response.data; } diff --git a/web/normogen-web/src/store/useStore.test.ts b/web/normogen-web/src/store/useStore.test.ts index 26cb6d7..059b750 100644 --- a/web/normogen-web/src/store/useStore.test.ts +++ b/web/normogen-web/src/store/useStore.test.ts @@ -3,9 +3,6 @@ import { deriveAuthAndEncKeys, setEncKey, clearEncKey, - setActiveProfileId, - setProfileDek, - clearProfileDeks, encryptJson, setupEncryption, unlockWithPassword, @@ -25,10 +22,6 @@ 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; @@ -37,10 +30,6 @@ 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: [], @@ -55,7 +44,6 @@ describe('useMedicationStore', () => { afterEach(() => { clearEncKey(); - clearProfileDeks(); }); it('loadMedications decrypts wire responses into domain medications', async () => { @@ -86,7 +74,6 @@ 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 22daa32..69211ee 100644 --- a/web/normogen-web/src/store/useStore.ts +++ b/web/normogen-web/src/store/useStore.ts @@ -6,28 +6,9 @@ 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, @@ -40,10 +21,6 @@ import { DrugInteraction, AdherenceStats, Profile, - ProfileShareListing, - MedicationWireResponse, - AppointmentWireResponse, - HealthStatWireResponse, Appointment, CreateAppointmentRequest, UpdateAppointmentRequest, @@ -61,12 +38,6 @@ 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; @@ -124,46 +95,11 @@ interface InteractionState { } interface ProfileState { - profiles: Profile[]; - activeProfileId: string | null; + profile: Profile | null; isLoading: boolean; error: string | null; - /** 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; + loadProfile: () => Promise; + updateName: (name: string) => Promise; clearError: () => void; } @@ -192,9 +128,6 @@ 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 }); @@ -211,26 +144,6 @@ 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). } @@ -247,9 +160,6 @@ 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(); @@ -271,21 +181,6 @@ 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, @@ -295,19 +190,7 @@ export const useAuthStore = create()( recovery_wrapped_dek: setup.recoveryWrappedDek?.data, recovery_wrapped_dek_iv: setup.recoveryWrappedDek?.iv, recovery_phrase: setup.recoveryKekHash, - 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); + } as any); set({ user: { user_id: response.user_id, @@ -320,9 +203,6 @@ 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({ @@ -374,8 +254,6 @@ export const useAuthStore = create()( logout: async () => { clearEncKey(); - clearIdentityPrivate(); - clearProfileDeks(); await apiService.logout(); set({ user: null, @@ -384,9 +262,6 @@ 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, }); }, @@ -421,9 +296,6 @@ 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, }), } ) @@ -440,14 +312,14 @@ export const useMedicationStore = create()( adherence: {}, loadMedications: async () => { - const key = getActiveProfileDek(); + const key = getEncKey(); 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(getActiveProfileId() ?? undefined); + const wireMeds = await apiService.getMedications(); // Decrypt each opaque blob into domain Medication objects. const medications = await Promise.all( wireMeds.map(async (w) => { @@ -487,7 +359,7 @@ export const useMedicationStore = create()( }, createMedication: async (data: any) => { - const key = getActiveProfileDek(); + const key = getEncKey(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); @@ -537,7 +409,7 @@ export const useMedicationStore = create()( }, updateMedication: async (id: string, data: any) => { - const key = getActiveProfileDek(); + const key = getEncKey(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); @@ -636,14 +508,14 @@ export const useHealthStore = create()( error: null, loadStats: async () => { - const key = getActiveProfileDek(); + const key = getEncKey(); 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(getActiveProfileId() ?? undefined); + const wireStats = await apiService.getHealthStats(); const stats = await Promise.all( wireStats.map(async (w) => { const data = await decryptJson>(w.encrypted_data, key); @@ -670,7 +542,7 @@ export const useHealthStore = create()( }, createStat: async (data: any) => { - const key = getActiveProfileDek(); + const key = getEncKey(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); @@ -678,7 +550,6 @@ 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, @@ -686,7 +557,6 @@ export const useHealthStore = create()( notes: d.notes, }, key); const wire = await apiService.createHealthStat({ - profile_id: profileId ?? '', encrypted_data, recorded_at: d.measured_at, }); @@ -713,7 +583,7 @@ export const useHealthStore = create()( }, updateStat: async (id: string, data: any) => { - const key = getActiveProfileDek(); + const key = getEncKey(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); @@ -825,159 +695,71 @@ export const useInteractionStore = create()( })) ); -// Profile Store (Phase A2: multi-profile + per-profile DEKs) +// Profile Store (Phase 3c) export const useProfileStore = create()( devtools((set, get) => ({ - profiles: [], - activeProfileId: null, + profile: null, isLoading: false, error: null, - profileShares: {}, - loadProfiles: async () => { - const accountDek = getEncKey(); - if (!accountDek) { - set({ error: 'No account key — log in to decrypt profiles', isLoading: false }); + loadProfile: async () => { + const key = getEncKey(); + if (!key) { + set({ error: 'No encryption key — log in to decrypt data', isLoading: false }); return; } set({ isLoading: true, error: null }); try { - 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 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 = ''; } } - // 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 profiles', - isLoading: false, - }); - } - }, - - 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 { - // 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, - owner_account_id: wire.owner_account_id, + user_id: wire.user_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((state) => ({ - profiles: [...state.profiles, profile], - activeProfileId: wire.profile_id, - isLoading: false, - })); + set({ profile, isLoading: false }); } catch (error: any) { set({ - error: error.message || 'Failed to create profile', + error: error.message || 'Failed to load 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'); + updateName: async (name) => { + const key = getEncKey(); + if (!key) { + set({ error: 'No encryption key — cannot encrypt data' }); + throw new Error('No encryption 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, - })); + 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 */ } + const profile: Profile = { + profile_id: wire.profile_id, + user_id: wire.user_id, + name: decryptedName, + role: wire.role, + permissions: wire.permissions, + created_at: wire.created_at, + updated_at: wire.updated_at, + }; + set({ profile, isLoading: false }); } catch (error: any) { set({ error: error.message || 'Failed to update profile', @@ -987,272 +769,6 @@ 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 }), })), ); @@ -1265,14 +781,14 @@ export const useAppointmentStore = create()( error: null, loadAppointments: async (status) => { - const key = getActiveProfileDek(); + const key = getEncKey(); 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, getActiveProfileId() ?? undefined); + const wireAppts = await apiService.getAppointments(status); const appointments = await Promise.all( wireAppts.map(async (w) => { const data = await decryptJson>(w.encrypted_data, key); @@ -1305,7 +821,7 @@ export const useAppointmentStore = create()( }, createAppointment: async (data) => { - const key = getActiveProfileDek(); + const key = getEncKey(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); @@ -1351,7 +867,7 @@ export const useAppointmentStore = create()( }, updateAppointment: async (id, data) => { - const key = getActiveProfileDek(); + const key = getEncKey(); 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 9904310..67ccf49 100644 --- a/web/normogen-web/src/types/api.ts +++ b/web/normogen-web/src/types/api.ts @@ -42,11 +42,6 @@ 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 { @@ -59,25 +54,6 @@ 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 @@ -247,13 +223,11 @@ 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; } @@ -333,110 +307,18 @@ export interface UpdateAppointmentRequest { status?: string; } -// Profile wire response (opaque encrypted name + wrapped profile DEK) +// Profile wire response (opaque encrypted name) export interface ProfileWireResponse { profile_id: string; - owner_account_id: string; + user_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; @@ -469,18 +351,12 @@ export interface AdherenceStats { // Profile Types — match backend ProfileResponse. export interface Profile { profile_id: string; - owner_account_id: string; + user_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