diff --git a/.forgejo/workflows/lint-and-build.yml b/.forgejo/workflows/lint-and-build.yml index ea85199..61e15be 100644 --- a/.forgejo/workflows/lint-and-build.yml +++ b/.forgejo/workflows/lint-and-build.yml @@ -111,12 +111,8 @@ jobs: image: mongo:7 env: MONGO_INITDB_DATABASE: normogen_test - # No `ports:` mapping: the job container reaches Mongo over the job's - # internal network via the service hostname (MONGODB_URI uses `mongo`, - # not localhost). Publishing to a host port is unnecessary and collides - # with any mongod already bound to :27017 on the runner host — which - # made the service container fail to schedule ("port is already - # allocated") and aborted the whole test job before any test ran. + ports: + - 27017:27017 env: MONGODB_URI: mongodb://mongo:27017 APP_ENVIRONMENT: development 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/jwt-authentication-decision.md b/docs/adr/jwt-authentication-decision.md index 8af389d..b8d2190 100644 --- a/docs/adr/jwt-authentication-decision.md +++ b/docs/adr/jwt-authentication-decision.md @@ -65,10 +65,10 @@ Note what is **absent** and why: 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.) + - `backend/src/auth/claims.rs` *does* define an `AccessClaims` struct with + `family_id`, `permissions`, `token_type`, `jti`. **It is dead code** — not + imported or used anywhere in the source (only in stale compiler scratch + files under `target/`). It should be removed or wired up; tracked by issue #4. - **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. @@ -151,13 +151,13 @@ never on the server to protect: - 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). +- The dead `AccessClaims` struct is a latent trap — someone could wire it up + assuming `family_id`/`permissions` are enforced. Should be deleted. ## 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. +- **Remove `backend/src/auth/claims.rs`** (dead `AccessClaims`) or wire it up + intentionally. Tracked under issue #4. - 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. 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