diff --git a/backend/src/handlers/appointments.rs b/backend/src/handlers/appointments.rs index 9e7ab8b..9d9f054 100644 --- a/backend/src/handlers/appointments.rs +++ b/backend/src/handlers/appointments.rs @@ -33,23 +33,12 @@ pub async fn create_appointment( let now = SystemTime::now(); let appointment_id = uuid::Uuid::new_v4().to_string(); - // Pack the request fields into the data blob (camelCase keys). + // Zero-knowledge: store the client's opaque encrypted blob verbatim. let appointment_data = EncryptedField { - data: serde_json::json!({ - "title": req.title, - "provider": req.provider, - "appointmentType": req.appointment_type, - "dateTime": req.date_time, - "location": req.location, - "durationMinutes": req.duration_minutes, - "reason": req.reason, - "notes": req.notes, - "status": req.status.unwrap_or_else(|| "upcoming".to_string()), - }) - .to_string(), - encrypted: false, - iv: String::new(), - auth_tag: String::new(), + data: req.encrypted_data.data, + encrypted: true, + iv: req.encrypted_data.iv, + auth_tag: req.encrypted_data.auth_tag, }; let appointment = crate::models::appointment::Appointment { @@ -59,6 +48,7 @@ pub async fn create_appointment( profile_id: req.profile_id.clone(), appointment_data, reminders: vec![], + status: req.status, created_at: DateTime::from_system_time(now), updated_at: DateTime::from_system_time(now), }; diff --git a/backend/src/handlers/medications.rs b/backend/src/handlers/medications.rs index 3ee8021..9a9beb8 100644 --- a/backend/src/handlers/medications.rs +++ b/backend/src/handlers/medications.rs @@ -31,28 +31,13 @@ pub async fn create_medication( let now = SystemTime::now(); let medication_id = uuid::Uuid::new_v4().to_string(); - // Build medication data JSON - let medication_data_value = serde_json::json!({ - "name": req.name, - "dosage": req.dosage, - "frequency": req.frequency, - "route": req.route, - "reason": req.reason, - "instructions": req.instructions, - "sideEffects": req.side_effects.unwrap_or_default(), - "prescribedBy": req.prescribed_by, - "prescribedDate": req.prescribed_date, - "startDate": req.start_date, - "endDate": req.end_date, - "notes": req.notes, - "tags": req.tags.unwrap_or_default(), - }); - + // Zero-knowledge: the client sends an already-encrypted blob; the server + // stores it verbatim and never inspects the contents. let medication_data = crate::models::health_data::EncryptedField { - data: medication_data_value.to_string(), - encrypted: false, - iv: String::new(), - auth_tag: String::new(), + data: req.encrypted_data.data, + encrypted: true, + iv: req.encrypted_data.iv, + auth_tag: req.encrypted_data.auth_tag, }; let medication = Medication { @@ -61,19 +46,11 @@ pub async fn create_medication( user_id: claims.sub, profile_id: req.profile_id.clone(), medication_data, - reminders: req - .reminder_times - .unwrap_or_default() - .into_iter() - .map(|time| crate::models::medication::MedicationReminder { - reminder_id: uuid::Uuid::new_v4().to_string(), - scheduled_time: time, - }) - .collect(), + reminders: vec![], created_at: now.into(), updated_at: now.into(), active: req.active, - pill_identification: req.pill_identification, // Phase 2.8 + pill_identification: None, }; match repo.create(medication).await { diff --git a/backend/src/handlers/profile.rs b/backend/src/handlers/profile.rs index c0cb77e..ea3d37e 100644 --- a/backend/src/handlers/profile.rs +++ b/backend/src/handlers/profile.rs @@ -5,7 +5,6 @@ use axum::{ }; use mongodb::bson::DateTime; use serde::{Deserialize, Serialize}; -use validator::Validate; use crate::{ auth::jwt::Claims, @@ -13,12 +12,15 @@ use crate::{ models::profile::{Profile, ProfileRepository}, }; -/// The profile as exposed to clients (omits the internal encryption fields). +/// 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 user_id: String, - pub name: String, + /// Opaque client-encrypted display name. + pub name_data: String, + pub name_iv: String, pub role: String, pub permissions: Vec, pub created_at: DateTime, @@ -30,7 +32,8 @@ impl From for ProfileResponse { Self { profile_id: p.profile_id, user_id: p.user_id, - name: p.name, + name_data: p.name, + name_iv: p.name_iv, role: p.role, permissions: p.permissions, created_at: p.created_at, @@ -41,17 +44,17 @@ impl From for ProfileResponse { /// 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. -pub fn build_default_profile(user_id: &str, name: &str) -> Profile { +/// 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_id: format!("profile_{user_id}"), user_id: user_id.to_string(), family_id: None, - // TODO: encrypt the name (the model anticipates nameIv/nameAuthTag, but - // no crypto layer is implemented yet). - name: name.to_string(), + name: String::new(), name_iv: String::new(), name_auth_tag: String::new(), role: "patient".to_string(), @@ -61,10 +64,13 @@ pub fn build_default_profile(user_id: &str, name: &str) -> Profile { } } -#[derive(Debug, Deserialize, Validate)] +/// PUT /profiles/me body — the client sends the encrypted name blob. +#[derive(Debug, Deserialize)] pub struct UpdateProfileNameRequest { - #[validate(length(min = 1, max = 100))] - pub name: String, + pub name_data: String, + pub name_iv: String, + #[serde(default)] + pub name_auth_tag: String, } /// GET /api/profiles/me — the current user's profile. If for some reason the @@ -102,25 +108,24 @@ pub async fn get_my_profile( Ok(Json(ProfileResponse::from(profile))) } -/// PUT /api/profiles/me — update the profile's display name. +/// PUT /api/profiles/me — update the profile's encrypted display-name blob. pub async fn update_my_profile( State(state): State, Extension(claims): Extension, Json(req): Json, ) -> Result, (StatusCode, Json)> { - if let Err(errors) = req.validate() { - return Err(( - StatusCode::BAD_REQUEST, - Json( - serde_json::json!({ "error": "validation failed", "details": errors.to_string() }), - ), - )); - } - let database = state.db.get_database(); let repo = ProfileRepository::new(database.collection("profiles")); - match repo.update_name(&claims.sub, &req.name).await { + match repo + .update_encrypted_name( + &claims.sub, + &req.name_data, + &req.name_iv, + &req.name_auth_tag, + ) + .await + { Ok(Some(updated)) => Ok(Json(ProfileResponse::from(updated))), Ok(None) => Err(( StatusCode::NOT_FOUND, diff --git a/backend/src/models/appointment.rs b/backend/src/models/appointment.rs index 0f19b4f..d6d5efb 100644 --- a/backend/src/models/appointment.rs +++ b/backend/src/models/appointment.rs @@ -4,74 +4,37 @@ use mongodb::bson::{doc, oid::ObjectId, DateTime}; use mongodb::Collection; use serde::{Deserialize, Serialize}; -// ============================================================================ -// APPOINTMENT MODEL -// ============================================================================ - -/// The structured content stored (as a JSON string) inside -/// `appointment_data.data`. Deserializes from the camelCase keys the create -/// handler packs in. Mirrors the MedicationData pattern. -#[derive(Debug, Clone, Deserialize, Default)] -pub struct AppointmentData { - pub title: String, - pub provider: String, - #[serde(rename = "appointmentType")] - pub appointment_type: String, - #[serde(rename = "dateTime")] - pub date_time: String, - pub location: Option, - #[serde(rename = "durationMinutes")] - pub duration_minutes: Option, - pub reason: Option, - pub notes: Option, - #[serde(default = "default_status")] - pub status: String, -} - fn default_status() -> String { "upcoming".to_string() } -/// The flat, snake_case appointment representation returned by the API. Built -/// from a stored `Appointment` by deserializing its `appointment_data.data` blob. +/// Zero-knowledge wire response. The server returns ONLY metadata + the opaque +/// encrypted data blob + the top-level `status` (filterable without decrypting). #[derive(Debug, Serialize)] pub struct AppointmentResponse { pub id: String, pub appointment_id: String, pub user_id: String, pub profile_id: String, - pub title: String, - pub provider: String, - pub appointment_type: String, - pub date_time: String, - pub location: Option, - pub duration_minutes: Option, - pub reason: Option, - pub notes: Option, pub status: String, + pub encrypted_data: crate::models::medication::EncryptedFieldWire, pub created_at: String, pub updated_at: String, } impl std::convert::From for AppointmentResponse { fn from(a: Appointment) -> Self { - let data: AppointmentData = - serde_json::from_str(&a.appointment_data.data).unwrap_or_default(); - AppointmentResponse { id: a.id.map(|o| o.to_hex()).unwrap_or_default(), appointment_id: a.appointment_id, user_id: a.user_id, profile_id: a.profile_id, - title: data.title, - provider: data.provider, - appointment_type: data.appointment_type, - date_time: data.date_time, - location: data.location, - duration_minutes: data.duration_minutes, - reason: data.reason, - notes: data.notes, - status: data.status, + status: a.status, + encrypted_data: crate::models::medication::EncryptedFieldWire { + data: a.appointment_data.data, + iv: a.appointment_data.iv, + auth_tag: a.appointment_data.auth_tag, + }, created_at: system_time_to_rfc3339(a.created_at.to_system_time()), updated_at: system_time_to_rfc3339(a.updated_at.to_system_time()), } @@ -103,6 +66,9 @@ pub struct Appointment { pub appointment_data: EncryptedField, #[serde(rename = "reminders")] pub reminders: Vec, + /// Top-level status so it can be filtered without decrypting the blob. + #[serde(rename = "status", default = "default_status")] + pub status: String, #[serde(rename = "createdAt")] pub created_at: DateTime, #[serde(rename = "updatedAt")] @@ -118,34 +84,21 @@ pub struct AppointmentReminder { } // ============================================================================ -// REQUEST TYPES +// REQUEST TYPES — opaque blobs; the client encrypts, the server stores verbatim. // ============================================================================ #[derive(Debug, Deserialize)] pub struct CreateAppointmentRequest { - pub title: String, - pub provider: String, - pub appointment_type: String, - pub date_time: String, pub profile_id: String, - pub location: Option, - pub duration_minutes: Option, - pub reason: Option, - pub notes: Option, - /// Defaults to "upcoming" when omitted. - pub status: Option, + pub encrypted_data: crate::models::medication::EncryptedFieldWire, + /// Defaults to "upcoming". + #[serde(default = "default_status")] + pub status: String, } #[derive(Debug, Deserialize)] pub struct UpdateAppointmentRequest { - pub title: Option, - pub provider: Option, - pub appointment_type: Option, - pub date_time: Option, - pub location: Option, - pub duration_minutes: Option, - pub reason: Option, - pub notes: Option, + pub encrypted_data: Option, pub status: Option, } @@ -173,27 +126,21 @@ impl AppointmentRepository { Ok(appointment) } - /// List a user's appointments, optionally filtered by status (a field - /// inside the data blob — so filter post-query in Rust). + /// 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>, ) -> Result, Box> { - let filter = doc! { "userId": user_id }; + let mut filter = doc! { "userId": user_id }; + if let Some(s) = status { + filter.insert("status", s); + } let mut cursor = self.collection.find(filter, None).await?; let mut appointments = Vec::new(); while let Some(appt) = cursor.next().await { appointments.push(appt?); } - // status lives inside the data blob; filter here. - if let Some(wanted) = status { - appointments.retain(|a| { - serde_json::from_str::(&a.appointment_data.data) - .map(|d| d.status == wanted) - .unwrap_or(false) - }); - } Ok(appointments) } @@ -205,65 +152,23 @@ impl AppointmentRepository { Ok(self.collection.find_one(filter, None).await?) } + /// Whole-blob replace — the client re-encrypts and sends the new blob. pub async fn update_by_appointment_id( &self, appointment_id: &str, updates: UpdateAppointmentRequest, ) -> Result, Box> { let filter = doc! { "appointmentId": appointment_id }; - let existing = self.collection.find_one(filter.clone(), None).await?; - let Some(existing) = existing else { - return Ok(None); - }; + let mut update_doc = doc! { "updatedAt": DateTime::now() }; - let mut data: AppointmentData = - serde_json::from_str(&existing.appointment_data.data).unwrap_or_default(); - - if let Some(v) = updates.title { - data.title = v; + if let Some(blob) = updates.encrypted_data { + update_doc.insert("appointmentData.data", blob.data); + update_doc.insert("appointmentData.iv", blob.iv); + update_doc.insert("appointmentData.authTag", blob.auth_tag); } - if let Some(v) = updates.provider { - data.provider = v; + if let Some(status) = updates.status { + update_doc.insert("status", status); } - if let Some(v) = updates.appointment_type { - data.appointment_type = v; - } - if let Some(v) = updates.date_time { - data.date_time = v; - } - if let Some(v) = updates.location { - data.location = Some(v); - } - if let Some(v) = updates.duration_minutes { - data.duration_minutes = Some(v); - } - if let Some(v) = updates.reason { - data.reason = Some(v); - } - if let Some(v) = updates.notes { - data.notes = Some(v); - } - if let Some(v) = updates.status { - data.status = v; - } - - let data_json = serde_json::json!({ - "title": data.title, - "provider": data.provider, - "appointmentType": data.appointment_type, - "dateTime": data.date_time, - "location": data.location, - "durationMinutes": data.duration_minutes, - "reason": data.reason, - "notes": data.notes, - "status": data.status, - }) - .to_string(); - - let update_doc = doc! { - "appointmentData.data": data_json, - "updatedAt": DateTime::now(), - }; Ok(self .collection @@ -286,60 +191,28 @@ mod tests { use super::*; #[test] - fn appointment_data_deserializes_camel_case_blob() { - let json = r#"{ - "title": "Cardiology follow-up", - "provider": "Dr. House", - "appointmentType": "in-person", - "dateTime": "2026-07-01T10:00:00Z", - "location": "Main Clinic", - "durationMinutes": 30, - "reason": "check-up", - "notes": "bring results", - "status": "upcoming" - }"#; - let data: AppointmentData = serde_json::from_str(json).unwrap(); - assert_eq!(data.title, "Cardiology follow-up"); - assert_eq!(data.appointment_type, "in-person"); - assert_eq!(data.duration_minutes, Some(30)); - assert_eq!(data.status, "upcoming"); - } - - #[test] - fn appointment_data_defaults_status_when_missing() { - let json = r#"{"title":"X","provider":"Y","appointmentType":"lab","dateTime":"2026-07-01T00:00:00Z"}"#; - let data: AppointmentData = serde_json::from_str(json).unwrap(); - assert_eq!(data.status, "upcoming"); - } - - #[test] - fn appointment_response_flattens() { - let blob = serde_json::json!({ - "title": "Dentist", "provider": "Dr. Molar", "appointmentType": "in-person", - "dateTime": "2026-08-01T09:00:00Z", "location": null, - "durationMinutes": null, "reason": null, "notes": null, "status": "completed" - }) - .to_string(); + fn appointment_response_echoes_opaque_blob_and_status() { let appt = Appointment { id: Some(ObjectId::new()), appointment_id: "apt-1".to_string(), user_id: "user-1".to_string(), profile_id: "profile_user-1".to_string(), appointment_data: EncryptedField { - data: blob, - encrypted: false, - iv: String::new(), + data: "opaque-ciphertext".to_string(), + encrypted: true, + iv: "base64-iv".to_string(), auth_tag: String::new(), }, reminders: vec![], + status: "completed".to_string(), created_at: DateTime::now(), updated_at: DateTime::now(), }; + let resp: AppointmentResponse = appt.into(); assert_eq!(resp.appointment_id, "apt-1"); - assert_eq!(resp.title, "Dentist"); - assert_eq!(resp.appointment_type, "in-person"); assert_eq!(resp.status, "completed"); + assert_eq!(resp.encrypted_data.data, "opaque-ciphertext"); assert!(!resp.id.is_empty()); } } diff --git a/backend/src/models/medication.rs b/backend/src/models/medication.rs index 7bbbadd..9498a10 100644 --- a/backend/src/models/medication.rs +++ b/backend/src/models/medication.rs @@ -96,85 +96,47 @@ pub struct AdherenceStats { // MEDICATION MODEL (Existing + Phase 2.8 updates) // ============================================================================ -/// The structured content stored (as a JSON string) inside `medication_data.data`. -/// Deserializes from the camelCase keys the create handler packs in. This is the -/// intermediate type used to flatten a `Medication` into a `MedicationResponse`. -#[derive(Debug, Clone, Deserialize, Default)] -pub struct MedicationData { - pub name: String, - pub dosage: String, - pub frequency: String, - pub route: String, - pub reason: Option, - pub instructions: Option, - #[serde(rename = "sideEffects", default)] - pub side_effects: Vec, - #[serde(rename = "prescribedBy")] - pub prescribed_by: Option, - #[serde(rename = "prescribedDate")] - pub prescribed_date: Option, - #[serde(rename = "startDate")] - pub start_date: Option, - #[serde(rename = "endDate")] - pub end_date: Option, - pub notes: Option, - #[serde(default)] - pub tags: Vec, -} - -/// The flat, snake_case medication representation returned by the API. Built from -/// a stored `Medication` by deserializing its `medication_data.data` blob. -/// Timestamps are ISO 8601 strings; `active` reflects the persisted flag. +/// Zero-knowledge wire response. The server returns ONLY metadata + the opaque +/// encrypted data blob (which the client decrypts). The server cannot read +/// name/dosage/etc. — those live encrypted inside `encrypted_data`. #[derive(Debug, Serialize)] pub struct MedicationResponse { pub id: String, pub medication_id: String, pub user_id: String, pub profile_id: String, - pub name: String, - pub dosage: String, - pub frequency: String, - pub route: String, - pub reason: Option, - pub instructions: Option, - pub side_effects: Vec, - pub prescribed_by: Option, - pub prescribed_date: Option, - pub start_date: Option, - pub end_date: Option, - pub notes: Option, - pub tags: Vec, pub active: bool, + /// Opaque client-encrypted blob (base64 ciphertext + iv). The server stores + /// and returns this verbatim; only the client can decrypt it. + pub encrypted_data: EncryptedFieldWire, pub created_at: String, pub updated_at: String, } +/// The opaque-encryption wire shape for an `EncryptedField`, matching what the +/// client sends and what the server stores (no `encrypted` bool needed on the +/// wire — everything is now ciphertext). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EncryptedFieldWire { + pub data: String, + pub iv: String, + #[serde(default)] + pub auth_tag: String, +} + impl std::convert::From for MedicationResponse { fn from(m: Medication) -> Self { - // The stored data is a JSON string; tolerate old/malformed rows by - // falling back to empty defaults rather than failing the whole response. - let data: MedicationData = - serde_json::from_str(&m.medication_data.data).unwrap_or_default(); - MedicationResponse { id: m.id.map(|o| o.to_hex()).unwrap_or_default(), medication_id: m.medication_id, user_id: m.user_id, profile_id: m.profile_id, - name: data.name, - dosage: data.dosage, - frequency: data.frequency, - route: data.route, - reason: data.reason, - instructions: data.instructions, - side_effects: data.side_effects, - prescribed_by: data.prescribed_by, - prescribed_date: data.prescribed_date, - start_date: data.start_date, - end_date: data.end_date, - notes: data.notes, - tags: data.tags, active: m.active, + encrypted_data: EncryptedFieldWire { + data: m.medication_data.data, + iv: m.medication_data.iv, + auth_tag: m.medication_data.auth_tag, + }, created_at: system_time_to_rfc3339(m.created_at.to_system_time()), updated_at: system_time_to_rfc3339(m.updated_at.to_system_time()), } @@ -258,52 +220,21 @@ pub struct MedicationDose { #[derive(Debug, Deserialize)] pub struct CreateMedicationRequest { - pub name: String, - pub dosage: String, - pub frequency: String, - pub route: String, - pub reason: Option, - pub instructions: Option, - pub side_effects: Option>, - pub prescribed_by: Option, - pub prescribed_date: Option, - pub start_date: Option, - pub end_date: Option, - pub notes: Option, - pub tags: Option>, - pub reminder_times: Option>, pub profile_id: String, + /// Opaque client-encrypted data blob. The server stores this verbatim. + pub encrypted_data: EncryptedFieldWire, /// Whether the medication is active on creation (defaults to true). #[serde(default = "default_true")] pub active: bool, - - /// Pill identification (Phase 2.8 - optional) - #[serde(rename = "pill_identification")] - pub pill_identification: Option, } #[derive(Debug, Deserialize)] pub struct UpdateMedicationRequest { - pub name: Option, - pub dosage: Option, - pub frequency: Option, - pub route: Option, - pub reason: Option, - pub instructions: Option, - pub side_effects: Option>, - pub prescribed_by: Option, - pub prescribed_date: Option, - pub start_date: Option, - pub end_date: Option, - pub notes: Option, - pub tags: Option>, - pub reminder_times: Option>, + /// Replacement opaque client-encrypted data blob (whole-blob replace — the + /// client re-encrypts the full record on edit). + pub encrypted_data: Option, /// Set the medication active/inactive. pub active: Option, - - /// Pill identification (Phase 2.8 - optional) - #[serde(rename = "pill_identification")] - pub pill_identification: Option, } #[derive(Debug, Deserialize)] @@ -411,90 +342,24 @@ impl MedicationRepository { /// Update by the application-level medication_id (UUID). The API exposes /// medication_id in URLs, not the Mongo _id. + /// Whole-blob replace by medication_id (UUID). The client re-encrypts the + /// full record and sends the new blob; the server never inspects its contents. pub async fn update_by_medication_id( &self, medication_id: &str, updates: UpdateMedicationRequest, ) -> Result, Box> { let filter = doc! { "medicationId": medication_id }; - let existing = self.collection.find_one(filter.clone(), None).await?; - let Some(existing) = existing else { - return Ok(None); - }; + let mut update_doc = doc! { "updatedAt": DateTime::now() }; - let mut data: MedicationData = - serde_json::from_str(&existing.medication_data.data).unwrap_or_default(); - - if let Some(v) = updates.name { - data.name = v; + if let Some(blob) = updates.encrypted_data { + update_doc.insert("medicationData.data", blob.data); + update_doc.insert("medicationData.iv", blob.iv); + update_doc.insert("medicationData.authTag", blob.auth_tag); } - if let Some(v) = updates.dosage { - data.dosage = v; - } - if let Some(v) = updates.frequency { - data.frequency = v; - } - if let Some(v) = updates.route { - data.route = v; - } - if let Some(v) = updates.reason { - data.reason = Some(v); - } - if let Some(v) = updates.instructions { - data.instructions = Some(v); - } - if let Some(v) = updates.side_effects { - data.side_effects = v; - } - if let Some(v) = updates.prescribed_by { - data.prescribed_by = Some(v); - } - if let Some(v) = updates.prescribed_date { - data.prescribed_date = Some(v); - } - if let Some(v) = updates.start_date { - data.start_date = Some(v); - } - if let Some(v) = updates.end_date { - data.end_date = Some(v); - } - if let Some(v) = updates.notes { - data.notes = Some(v); - } - if let Some(v) = updates.tags { - data.tags = v; - } - // `active` lives on the document, not in the data blob. - - let data_json = serde_json::json!({ - "name": data.name, - "dosage": data.dosage, - "frequency": data.frequency, - "route": data.route, - "reason": data.reason, - "instructions": data.instructions, - "sideEffects": data.side_effects, - "prescribedBy": data.prescribed_by, - "prescribedDate": data.prescribed_date, - "startDate": data.start_date, - "endDate": data.end_date, - "notes": data.notes, - "tags": data.tags, - }) - .to_string(); - - let mut update_doc = doc! { - "medicationData.data": data_json, - "updatedAt": DateTime::now(), - }; if let Some(active) = updates.active { update_doc.insert("active", active); } - if let Some(pill_identification) = updates.pill_identification { - if let Ok(pill_doc) = mongodb::bson::to_document(&pill_identification) { - update_doc.insert("pill_identification", pill_doc); - } - } let medication = self .collection @@ -513,97 +378,23 @@ impl MedicationRepository { Ok(result.deleted_count > 0) } + /// Whole-blob replace by Mongo _id (unused by routes but kept for completeness). pub async fn update( &self, id: &ObjectId, updates: UpdateMedicationRequest, ) -> Result, Box> { - // The stored shape is `medicationData: { data: "", ... }`. - // The previous implementation used dot-notation ("medicationData.name") - // which wrote into phantom paths. Instead: load the existing document, - // deserialize its data blob, apply the overrides, re-serialize, and - // $set the whole `medicationData.data` string. let filter = doc! { "_id": id }; - let existing = self.collection.find_one(filter.clone(), None).await?; - let Some(existing) = existing else { - return Ok(None); - }; + let mut update_doc = doc! { "updatedAt": DateTime::now() }; - let mut data: MedicationData = - serde_json::from_str(&existing.medication_data.data).unwrap_or_default(); - - if let Some(v) = updates.name { - data.name = v; + if let Some(blob) = updates.encrypted_data { + update_doc.insert("medicationData.data", blob.data); + update_doc.insert("medicationData.iv", blob.iv); + update_doc.insert("medicationData.authTag", blob.auth_tag); } - if let Some(v) = updates.dosage { - data.dosage = v; - } - if let Some(v) = updates.frequency { - data.frequency = v; - } - if let Some(v) = updates.route { - data.route = v; - } - if let Some(v) = updates.reason { - data.reason = Some(v); - } - if let Some(v) = updates.instructions { - data.instructions = Some(v); - } - if let Some(v) = updates.side_effects { - data.side_effects = v; - } - if let Some(v) = updates.prescribed_by { - data.prescribed_by = Some(v); - } - if let Some(v) = updates.prescribed_date { - data.prescribed_date = Some(v); - } - if let Some(v) = updates.start_date { - data.start_date = Some(v); - } - if let Some(v) = updates.end_date { - data.end_date = Some(v); - } - if let Some(v) = updates.notes { - data.notes = Some(v); - } - if let Some(v) = updates.tags { - data.tags = v; - } - // `active` lives on the document, not in the data blob. - - // Re-serialize with the SAME camelCase keys the create handler uses, so - // reads stay consistent. - let data_json = serde_json::json!({ - "name": data.name, - "dosage": data.dosage, - "frequency": data.frequency, - "route": data.route, - "reason": data.reason, - "instructions": data.instructions, - "sideEffects": data.side_effects, - "prescribedBy": data.prescribed_by, - "prescribedDate": data.prescribed_date, - "startDate": data.start_date, - "endDate": data.end_date, - "notes": data.notes, - "tags": data.tags, - }) - .to_string(); - - let mut update_doc = doc! { - "medicationData.data": data_json, - "updatedAt": DateTime::now(), - }; if let Some(active) = updates.active { update_doc.insert("active", active); } - if let Some(pill_identification) = updates.pill_identification { - if let Ok(pill_doc) = mongodb::bson::to_document(&pill_identification) { - update_doc.insert("pill_identification", pill_doc); - } - } let medication = self .collection @@ -629,59 +420,18 @@ mod tests { use crate::models::health_data::EncryptedField; #[test] - fn medication_data_deserializes_camel_case_blob() { - // This is the exact shape the create handler packs into the data string. - let json = r#"{ - "name": "Ibuprofen", - "dosage": "200mg", - "frequency": "as needed", - "route": "oral", - "reason": null, - "instructions": "with food", - "sideEffects": ["nausea"], - "prescribedBy": "Dr. House", - "prescribedDate": "2026-01-01", - "startDate": "2026-01-02", - "endDate": null, - "notes": "take care", - "tags": ["pain"] - }"#; - let data: MedicationData = serde_json::from_str(json).unwrap(); - assert_eq!(data.name, "Ibuprofen"); - assert_eq!(data.side_effects, vec!["nausea".to_string()]); - assert_eq!(data.prescribed_by.as_deref(), Some("Dr. House")); - assert_eq!(data.tags, vec!["pain".to_string()]); - assert_eq!(data.route, "oral"); - } - - #[test] - fn medication_data_defaults_empty_arrays_for_missing_keys() { - // Old rows may lack sideEffects/tags; the #[default] should yield empty vecs. - let json = r#"{"name":"X","dosage":"1","frequency":"daily","route":"oral"}"#; - let data: MedicationData = serde_json::from_str(json).unwrap(); - assert!(data.side_effects.is_empty()); - assert!(data.tags.is_empty()); - } - - #[test] - fn medication_response_flattens_and_uses_snake_case_fields() { - let blob = serde_json::json!({ - "name": "Aspirin", "dosage": "100mg", "frequency": "daily", "route": "oral", - "reason": null, "instructions": null, - "sideEffects": ["bleeding"], "prescribedBy": null, "prescribedDate": null, - "startDate": null, "endDate": null, "notes": null, "tags": [] - }) - .to_string(); - + fn medication_response_echoes_opaque_blob_unchanged() { + // Zero-knowledge: the server returns the ciphertext blob verbatim; it + // never inspects the contents. The blob here is opaque base64 ciphertext. let med = Medication { id: Some(ObjectId::new()), medication_id: "med-123".to_string(), user_id: "user-1".to_string(), profile_id: "profile_user-1".to_string(), medication_data: EncryptedField { - data: blob, - encrypted: false, - iv: String::new(), + data: "opaque-base64-ciphertext".to_string(), + encrypted: true, + iv: "base64-iv".to_string(), auth_tag: String::new(), }, reminders: vec![], @@ -693,35 +443,10 @@ mod tests { let resp: MedicationResponse = med.into(); assert_eq!(resp.medication_id, "med-123"); - assert_eq!(resp.name, "Aspirin"); - assert_eq!(resp.side_effects, vec!["bleeding".to_string()]); + assert_eq!(resp.encrypted_data.data, "opaque-base64-ciphertext"); + assert_eq!(resp.encrypted_data.iv, "base64-iv"); assert!(resp.active); assert!(!resp.id.is_empty()); assert!(!resp.created_at.is_empty()); // ISO string } - - #[test] - fn medication_response_tolerates_malformed_data_blob() { - let med = Medication { - id: None, - medication_id: "m".to_string(), - user_id: "u".to_string(), - profile_id: "p".to_string(), - medication_data: EncryptedField { - data: "not valid json".to_string(), - encrypted: false, - iv: String::new(), - auth_tag: String::new(), - }, - reminders: vec![], - created_at: DateTime::now(), - updated_at: DateTime::now(), - active: true, - pill_identification: None, - }; - // Should NOT panic — falls back to defaults. - let resp: MedicationResponse = med.into(); - assert_eq!(resp.name, ""); - assert!(resp.active); - } } diff --git a/backend/src/models/profile.rs b/backend/src/models/profile.rs index f336ce3..3881064 100644 --- a/backend/src/models/profile.rs +++ b/backend/src/models/profile.rs @@ -60,21 +60,21 @@ impl ProfileRepository { .await } - /// Update the profile's display name. NOTE: name is currently stored - /// plaintext — the model's nameIv/nameAuthTag fields anticipate encryption - /// that isn't implemented yet (TODO). - pub async fn update_name( + /// Update the profile's display name (opaque client-encrypted blob). + pub async fn update_encrypted_name( &self, user_id: &str, - name: &str, + name_data: &str, + name_iv: &str, + name_auth_tag: &str, ) -> mongodb::error::Result> { self.collection .find_one_and_update( doc! { "userId": user_id }, doc! { "$set": { - "name": name, - "nameIv": "", - "nameAuthTag": "", + "name": name_data, + "nameIv": name_iv, + "nameAuthTag": name_auth_tag, "updatedAt": DateTime::now() }}, None, diff --git a/web/normogen-web/src/components/appointments/AppointmentsManager.tsx b/web/normogen-web/src/components/appointments/AppointmentsManager.tsx index 7391943..d3ee7b0 100644 --- a/web/normogen-web/src/components/appointments/AppointmentsManager.tsx +++ b/web/normogen-web/src/components/appointments/AppointmentsManager.tsx @@ -25,22 +25,32 @@ import DeleteIcon from '@mui/icons-material/Delete'; import AddIcon from '@mui/icons-material/Add'; import { format } from 'date-fns'; import { useAppointmentStore, useAuthStore } from '../../store/useStore'; -import type { - Appointment, - CreateAppointmentRequest, - UpdateAppointmentRequest, -} from '../../types/api'; +import type { Appointment } from '../../types/api'; const APPT_TYPES = ['in-person', 'telehealth', 'lab', 'test', 'other']; const STATUSES = ['upcoming', 'completed', 'cancelled']; +// Domain form type (decrypted fields the UI collects; the store encrypts them). +interface ApptFormData { + title: string; + provider: string; + appointment_type: string; + date_time: string; + location?: string; + duration_minutes?: number; + reason?: string; + notes?: string; + status?: string; + profile_id?: string; +} + const statusColor = (status: string): 'success' | 'default' | 'error' => { if (status === 'upcoming') return 'success'; if (status === 'cancelled') return 'error'; return 'default'; }; -const emptyCreate: CreateAppointmentRequest = { +const emptyCreate: ApptFormData = { title: '', provider: '', appointment_type: 'in-person', @@ -67,9 +77,9 @@ export const AppointmentsManager: FC = () => { const user = useAuthStore((s) => s.user); const [createOpen, setCreateOpen] = useState(false); - const [createForm, setCreateForm] = useState(emptyCreate); + const [createForm, setCreateForm] = useState(emptyCreate); const [editTarget, setEditTarget] = useState(null); - const [editForm, setEditForm] = useState({}); + const [editForm, setEditForm] = useState>({}); const [deleteTarget, setDeleteTarget] = useState(null); const [saving, setSaving] = useState(false); @@ -88,7 +98,7 @@ export const AppointmentsManager: FC = () => { const submitCreate = async () => { setSaving(true); try { - await createAppointment(createForm); + await createAppointment(createForm as unknown as Record); setCreateOpen(false); } catch { /* error surfaced via store */ @@ -116,7 +126,7 @@ export const AppointmentsManager: FC = () => { if (!editTarget?.appointment_id) return; setSaving(true); try { - await updateAppointment(editTarget.appointment_id, editForm); + await updateAppointment(editTarget.appointment_id, editForm as unknown as Record); setEditTarget(null); } catch { /* error surfaced via store */ diff --git a/web/normogen-web/src/components/medication/MedicationManager.tsx b/web/normogen-web/src/components/medication/MedicationManager.tsx index 90f411d..e80679c 100644 --- a/web/normogen-web/src/components/medication/MedicationManager.tsx +++ b/web/normogen-web/src/components/medication/MedicationManager.tsx @@ -25,16 +25,23 @@ import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import AddIcon from '@mui/icons-material/Add'; import { useMedicationStore, useAuthStore } from '../../store/useStore'; -import type { - Medication, - CreateMedicationRequest, - UpdateMedicationRequest, -} from '../../types/api'; +import type { Medication } from '../../types/api'; import { DoseLogger } from './DoseLogger'; const ROUTES = ['oral', 'topical', 'injection', 'inhalation', 'other'] as const; -const emptyCreate: CreateMedicationRequest = { +// Domain form types (decrypted fields the UI collects; the store encrypts them). +interface MedFormData { + name: string; + dosage: string; + frequency: string; + route: string; + instructions?: string; + profile_id?: string; + active?: boolean; +} + +const emptyCreate: MedFormData = { name: '', dosage: '', frequency: '', @@ -56,9 +63,9 @@ export const MedicationManager: FC = () => { const user = useAuthStore((s) => s.user); const [createOpen, setCreateOpen] = useState(false); - const [createForm, setCreateForm] = useState(emptyCreate); + const [createForm, setCreateForm] = useState(emptyCreate); const [editTarget, setEditTarget] = useState(null); - const [editForm, setEditForm] = useState({}); + const [editForm, setEditForm] = useState>({}); const [deleteTarget, setDeleteTarget] = useState(null); const [saving, setSaving] = useState(false); @@ -238,7 +245,7 @@ export const MedicationManager: FC = () => { fullWidth value={createForm.route} onChange={(e) => - setCreateForm({ ...createForm, route: e.target.value as CreateMedicationRequest['route'] }) + setCreateForm({ ...createForm, route: e.target.value }) } > {ROUTES.map((r) => ( diff --git a/web/normogen-web/src/crypto/cipher.ts b/web/normogen-web/src/crypto/cipher.ts new file mode 100644 index 0000000..c9a4bb3 --- /dev/null +++ b/web/normogen-web/src/crypto/cipher.ts @@ -0,0 +1,81 @@ +/** + * AES-GCM encrypt/decrypt over the Web Crypto API (no dependencies). + * + * Ciphertext and IV are returned as base64 strings so they map directly onto + * the backend's `EncryptedField { data, iv, auth_tag }` wire shape. + */ + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +const b64 = { + encode(bytes: Uint8Array): string { + let bin = ''; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin); + }, + decode(str: string): Uint8Array { + const bin = atob(str); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; + }, +}; + +/** Random 12-byte IV for AES-GCM. */ +function randomIv(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(12)); +} + +export interface CipherPayload { + /** base64 ciphertext */ + data: string; + /** base64 12-byte IV */ + iv: string; +} + +/** Encrypt a UTF-8 string under the given AES-GCM key. */ +export async function encrypt( + plaintext: string, + key: CryptoKey, +): Promise { + const iv = randomIv(); + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv as BufferSource }, + key, + encoder.encode(plaintext) as BufferSource, + ); + return { + data: b64.encode(new Uint8Array(ciphertext)), + iv: b64.encode(iv), + }; +} + +/** Decrypt a base64 payload. Throws on tamper / wrong key. */ +export async function decrypt( + payload: CipherPayload, + key: CryptoKey, +): Promise { + const iv = b64.decode(payload.iv); + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: iv as BufferSource }, + key, + b64.decode(payload.data) as BufferSource, + ); + return decoder.decode(plaintext); +} + +/** Encrypt a JSON-serializable object. */ +export async function encryptJson( + obj: T, + key: CryptoKey, +): Promise { + return encrypt(JSON.stringify(obj), key); +} + +/** Decrypt a payload and JSON.parse into T. Throws on tamper / wrong key / bad JSON. */ +export async function decryptJson( + payload: CipherPayload, + key: CryptoKey, +): Promise { + return JSON.parse(await decrypt(payload, key)) as T; +} diff --git a/web/normogen-web/src/crypto/index.ts b/web/normogen-web/src/crypto/index.ts new file mode 100644 index 0000000..eabddc3 --- /dev/null +++ b/web/normogen-web/src/crypto/index.ts @@ -0,0 +1,16 @@ +export { + deriveAuthAndEncKeys, + setEncKey, + getEncKey, + clearEncKey, + hasEncKey, + AUTH_SALT, + ENC_SALT, +} from './keys'; +export { + encrypt, + decrypt, + encryptJson, + decryptJson, + type CipherPayload, +} from './cipher'; diff --git a/web/normogen-web/src/crypto/keys.ts b/web/normogen-web/src/crypto/keys.ts new file mode 100644 index 0000000..74920cb --- /dev/null +++ b/web/normogen-web/src/crypto/keys.ts @@ -0,0 +1,98 @@ +/** + * Zero-knowledge key derivation. + * + * From the user's password we derive TWO independent values via PBKDF2: + * + * - `authSecret`: base64 bytes sent to the server as the "password". The + * server PBKDF2-hashes it (as it always has). The server can never derive + * the encryption key from this value. + * - `encKey`: an AES-GCM CryptoKey kept in memory only (never persisted, + * never transmitted). Used to encrypt/decrypt all user data. + * + * Two separate PBKDF2 passes with app-wide domain-separation salts prevent the + * server from deriving `encKey` even if it logs or leaks `authSecret`. + */ + +const PBKDF2_ITERATIONS = 150_000; +const KEY_BITS = 256; // AES-256 + +const encoder = new TextEncoder(); + +export const AUTH_SALT = 'normogen-auth-v1'; +export const ENC_SALT = 'normogen-enc-v1'; + +/** + * Derive the auth secret (base64) and encryption key (CryptoKey) from a password. + */ +export async function deriveAuthAndEncKeys(password: string): Promise<{ + authSecret: string; + encKey: CryptoKey; +}> { + const passwordKey = await crypto.subtle.importKey( + 'raw', + encoder.encode(password), + 'PBKDF2', + false, + ['deriveBits'], + ); + + // Auth secret: 32 raw bytes, base64-encoded for transport. + const authBits = await crypto.subtle.deriveBits( + { name: 'PBKDF2', salt: encoder.encode(AUTH_SALT), iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, + passwordKey, + KEY_BITS, + ); + const authSecret = base64(new Uint8Array(authBits)); + + // Encryption key: importable AES-GCM key, non-extractable. + const encBits = await crypto.subtle.deriveBits( + { name: 'PBKDF2', salt: encoder.encode(ENC_SALT), iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, + passwordKey, + KEY_BITS, + ); + const encKey = await crypto.subtle.importKey( + 'raw', + encBits, + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'], + ); + + return { authSecret, encKey }; +} + +function base64(bytes: Uint8Array): string { + let bin = ''; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin); +} + +// --------------------------------------------------------------------------- +// In-memory encryption-key store. +// +// The encKey lives only in memory for the lifetime of the authenticated +// session (the tab). It is deliberately NOT persisted — losing the tab or +// closing the browser requires re-entering the password to re-derive it. +// --------------------------------------------------------------------------- + +let currentEncKey: CryptoKey | null = null; + +/** Set the session encryption key (called on login/register). */ +export function setEncKey(key: CryptoKey): void { + currentEncKey = key; +} + +/** Get the session encryption key, or null if not authenticated. */ +export function getEncKey(): CryptoKey | null { + return currentEncKey; +} + +/** Clear the session encryption key (called on logout). */ +export function clearEncKey(): void { + currentEncKey = null; +} + +/** True if a session encryption key is available (i.e. the user can encrypt/decrypt). */ +export function hasEncKey(): boolean { + return currentEncKey !== null; +} diff --git a/web/normogen-web/src/services/api.ts b/web/normogen-web/src/services/api.ts index 3cf213b..ca490d0 100644 --- a/web/normogen-web/src/services/api.ts +++ b/web/normogen-web/src/services/api.ts @@ -17,8 +17,10 @@ import { DoseLog, LogDoseRequest, AdherenceStats, - Profile, - Appointment, + MedicationWireResponse, + AppointmentWireResponse, + ProfileWireResponse, + EncryptedFieldWire, CreateAppointmentRequest, UpdateAppointmentRequest, } from '../types/api'; @@ -213,47 +215,48 @@ class ApiService { return response.data; } - // ---- Profile (Phase 3c) ---- + // ---- Profile (zero-knowledge: opaque encrypted name) ---- - async getProfile(): Promise { - const response = await this.client.get('/profiles/me'); + async getProfile(): Promise { + const response = await this.client.get('/profiles/me'); return response.data; } - async updateProfileName(name: string): Promise { - const response = await this.client.put('/profiles/me', { name }); + async updateProfileName(nameData: string, nameIv: string): Promise { + const response = await this.client.put('/profiles/me', { + name_data: nameData, + name_iv: nameIv, + }); return response.data; } - // ---- Medications ---- + // ---- Medications (zero-knowledge: opaque encrypted blobs) ---- - async getMedications(): Promise { - const response = await this.client.get('/medications'); + async getMedications(): Promise { + const response = await this.client.get('/medications'); return response.data; } - async getMedication(id: string): Promise { - const response = await this.client.get(`/medications/${id}`); + async getMedication(id: string): Promise { + const response = await this.client.get(`/medications/${id}`); return response.data; } - async createMedication(data: CreateMedicationRequest): Promise { - const response = await this.client.post('/medications', data); + async createMedication(data: CreateMedicationRequest): Promise { + const response = await this.client.post('/medications', data); return response.data; } - async updateMedication(id: string, data: UpdateMedicationRequest): Promise { - // Backend update is POST /:id (not PUT). - const response = await this.client.post(`/medications/${id}`, data); + async updateMedication(id: string, data: UpdateMedicationRequest): Promise { + const response = await this.client.post(`/medications/${id}`, data); return response.data; } async deleteMedication(id: string): Promise { - // Backend delete is POST /:id/delete (not DELETE /:id). await this.client.post(`/medications/${id}/delete`); } - // ---- Dose logging + adherence (Phase 3c) ---- + // ---- Dose logging + adherence (not encrypted — counts/flags only) ---- async logDose(medicationId: string, req: LogDoseRequest): Promise { const response = await this.client.post(`/medications/${medicationId}/log`, req); @@ -265,27 +268,27 @@ class ApiService { return response.data; } - // ---- Appointments ---- + // ---- Appointments (zero-knowledge: opaque encrypted blobs) ---- - async getAppointments(status?: string): Promise { - const response = await this.client.get('/appointments', { + async getAppointments(status?: string): Promise { + const response = await this.client.get('/appointments', { params: status ? { status } : undefined, }); return response.data; } - async getAppointment(id: string): Promise { - const response = await this.client.get(`/appointments/${id}`); + async getAppointment(id: string): Promise { + const response = await this.client.get(`/appointments/${id}`); return response.data; } - async createAppointment(data: CreateAppointmentRequest): Promise { - const response = await this.client.post('/appointments', data); + async createAppointment(data: CreateAppointmentRequest): Promise { + const response = await this.client.post('/appointments', data); return response.data; } - async updateAppointment(id: string, data: UpdateAppointmentRequest): Promise { - const response = await this.client.post(`/appointments/${id}`, data); + async updateAppointment(id: string, data: UpdateAppointmentRequest): Promise { + const response = await this.client.post(`/appointments/${id}`, data); return response.data; } diff --git a/web/normogen-web/src/store/useStore.test.ts b/web/normogen-web/src/store/useStore.test.ts index 2af1974..5024b07 100644 --- a/web/normogen-web/src/store/useStore.test.ts +++ b/web/normogen-web/src/store/useStore.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { deriveAuthAndEncKeys, setEncKey, clearEncKey } from '../crypto'; +import { encryptJson } from '../crypto'; // Mock the api client so the store's real reducer logic is exercised without HTTP. const apiMock = { @@ -13,52 +15,87 @@ vi.mock('../services/api', () => ({ default: apiMock })); const { useMedicationStore } = await import('./useStore'); describe('useMedicationStore', () => { - beforeEach(() => { + let encKey: CryptoKey; + + beforeEach(async () => { + // Derive a real encryption key so the store can encrypt/decrypt. + const { encKey: key } = await deriveAuthAndEncKeys('test-password'); + encKey = key; + setEncKey(encKey); + useMedicationStore.setState({ medications: [], isLoading: false, error: null, adherence: {}, + selectedMedication: null, }); apiMock.getMedications.mockReset(); apiMock.createMedication.mockReset(); }); - it('loadMedications populates state from the api client', async () => { - const meds = [{ medication_id: 'm1', name: 'Aspirin', dosage: '100mg' }]; - apiMock.getMedications.mockResolvedValue(meds); + afterEach(() => { + clearEncKey(); + }); + + it('loadMedications decrypts wire responses into domain medications', async () => { + // The API returns opaque wire blobs; the store decrypts them. + const blob = await encryptJson({ name: 'Aspirin', dosage: '100mg', frequency: 'daily' }, encKey); + apiMock.getMedications.mockResolvedValue([ + { + id: 'oid1', + medication_id: 'm1', + user_id: 'u1', + profile_id: 'p1', + active: true, + encrypted_data: blob, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + ]); await useMedicationStore.getState().loadMedications(); expect(apiMock.getMedications).toHaveBeenCalledOnce(); - expect(useMedicationStore.getState().medications).toEqual(meds); - expect(useMedicationStore.getState().isLoading).toBe(false); + const meds = useMedicationStore.getState().medications; + expect(meds).toHaveLength(1); + expect(meds[0].name).toBe('Aspirin'); + expect(meds[0].dosage).toBe('100mg'); + expect(meds[0].active).toBe(true); }); - it('loadMedications sets an error message on failure (and does not throw)', async () => { - apiMock.getMedications.mockRejectedValue(new Error('boom')); - - // Should NOT throw — load actions swallow. + it('loadMedications sets an error when no enc key is available', async () => { + clearEncKey(); await useMedicationStore.getState().loadMedications(); - - expect(useMedicationStore.getState().error).toBe('boom'); - expect(useMedicationStore.getState().isLoading).toBe(false); + expect(useMedicationStore.getState().error).toMatch(/No encryption key/); }); - it('createMedication appends the new medication and re-throws on error', async () => { - apiMock.createMedication.mockResolvedValue({ medication_id: 'm2', name: 'New' }); + it('createMedication encrypts domain data and stores the decrypted result', async () => { + const blob = await encryptJson({ name: 'New', dosage: '50mg', frequency: 'daily' }, encKey); + apiMock.createMedication.mockResolvedValue({ + id: 'oid2', + medication_id: 'm2', + user_id: 'u1', + profile_id: 'p1', + active: true, + encrypted_data: blob, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }); - await useMedicationStore.getState().createMedication({ name: 'New' }); + await useMedicationStore.getState().createMedication({ + name: 'New', + dosage: '50mg', + frequency: 'daily', + route: 'oral', + profile_id: 'p1', + active: true, + }); - expect(useMedicationStore.getState().medications).toEqual([ - { medication_id: 'm2', name: 'New' }, - ]); - - // Now an error path: createMedication rejects -> store re-throws. - apiMock.createMedication.mockRejectedValue(new Error('nope')); - await expect( - useMedicationStore.getState().createMedication({ name: 'Bad' }), - ).rejects.toThrow('nope'); + const meds = useMedicationStore.getState().medications; + expect(meds).toHaveLength(1); + expect(meds[0].name).toBe('New'); + expect(meds[0].dosage).toBe('50mg'); }); it('logDose logs the dose then refreshes adherence', async () => { diff --git a/web/normogen-web/src/store/useStore.ts b/web/normogen-web/src/store/useStore.ts index d471874..3e19a04 100644 --- a/web/normogen-web/src/store/useStore.ts +++ b/web/normogen-web/src/store/useStore.ts @@ -1,5 +1,13 @@ import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; +import { + deriveAuthAndEncKeys, + setEncKey, + clearEncKey, + getEncKey, + encryptJson, + decryptJson, +} from '../crypto'; import { User, Medication, @@ -88,8 +96,10 @@ interface AppointmentState { isLoading: boolean; error: string | null; loadAppointments: (status?: string) => Promise; - createAppointment: (data: CreateAppointmentRequest) => Promise; - updateAppointment: (id: string, data: UpdateAppointmentRequest) => Promise; + // The store receives DOMAIN data (decrypted fields from the UI) and encrypts + // it internally before sending to the server. + createAppointment: (data: Record) => Promise; + updateAppointment: (id: string, data: Record) => Promise; deleteAppointment: (id: string) => Promise; clearError: () => void; } @@ -108,7 +118,11 @@ export const useAuthStore = create()( login: async (email: string, password: string) => { set({ isLoading: true, error: null }); try { - const response = await apiService.login(email, password); + // Zero-knowledge: derive auth secret + encryption key from password. + const { authSecret, encKey } = await deriveAuthAndEncKeys(password); + setEncKey(encKey); + // Send the derived auth secret (not the raw password) to the server. + const response = await apiService.login(email, authSecret); set({ user: { user_id: response.user_id, @@ -121,6 +135,7 @@ export const useAuthStore = create()( isLoading: false, }); } catch (error: any) { + clearEncKey(); set({ error: error.message || 'Login failed', isLoading: false, @@ -133,11 +148,12 @@ export const useAuthStore = create()( register: async (username: string, email: string, password: string) => { set({ isLoading: true, error: null }); try { + const { authSecret, encKey } = await deriveAuthAndEncKeys(password); + setEncKey(encKey); const response = await apiService.register({ username, email, - password, - role: 'patient', + password: authSecret, }); set({ user: { @@ -161,6 +177,7 @@ export const useAuthStore = create()( }, logout: async () => { + clearEncKey(); await apiService.logout(); set({ user: null, @@ -215,9 +232,42 @@ export const useMedicationStore = create()( adherence: {}, loadMedications: 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 medications = await apiService.getMedications(); + const wireMeds = await apiService.getMedications(); + // Decrypt each opaque blob into domain Medication objects. + const medications = await Promise.all( + wireMeds.map(async (w) => { + const data = await decryptJson>(w.encrypted_data, key); + return { + id: w.id, + medication_id: w.medication_id, + user_id: w.user_id, + profile_id: w.profile_id, + name: (data.name as string) ?? '', + dosage: (data.dosage as string) ?? '', + frequency: (data.frequency as string) ?? '', + route: data.route as string | undefined, + reason: data.reason as string | undefined, + instructions: data.instructions as string | undefined, + side_effects: data.sideEffects as string[] | undefined, + prescribed_by: data.prescribedBy as string | undefined, + prescribed_date: data.prescribedDate as string | undefined, + start_date: data.startDate as string | undefined, + end_date: data.endDate as string | undefined, + notes: data.notes as string | undefined, + tags: data.tags as string[] | undefined, + active: w.active, + created_at: w.created_at, + updated_at: w.updated_at, + } as Medication; + }), + ); set({ medications, isLoading: false }); } catch (error: any) { set({ @@ -228,9 +278,42 @@ export const useMedicationStore = create()( }, createMedication: async (data: any) => { + 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 medication = await apiService.createMedication(data); + // Encrypt the domain fields into an opaque blob. + const { name, dosage, frequency, route, reason, instructions, + side_effects, prescribed_by, prescribed_date, start_date, + end_date, notes, tags, ...rest } = data; + const encrypted_data = await encryptJson({ + name, dosage, frequency, route, reason, instructions, + sideEffects: side_effects, prescribedBy: prescribed_by, + prescribedDate: prescribed_date, startDate: start_date, + endDate: end_date, notes, tags, + }, key); + const wire = await apiService.createMedication({ + profile_id: data.profile_id, + encrypted_data, + active: data.active, + }); + // Decrypt the response back into a domain Medication for the store. + const decrypted = await decryptJson>(wire.encrypted_data, key); + const medication = { + id: wire.id, + medication_id: wire.medication_id, + user_id: wire.user_id, + profile_id: wire.profile_id, + name: (decrypted.name as string) ?? '', + dosage: (decrypted.dosage as string) ?? '', + frequency: (decrypted.frequency as string) ?? '', + active: wire.active, + created_at: wire.created_at, + updated_at: wire.updated_at, + } as Medication; set((state) => ({ medications: [...state.medications, medication], isLoading: false, @@ -245,12 +328,33 @@ export const useMedicationStore = create()( }, updateMedication: async (id: string, data: any) => { + 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 updated = await apiService.updateMedication(id, data); + // Encrypt the full updated domain record into a new blob. + const { name, dosage, frequency, route, reason, instructions, + side_effects, prescribed_by, prescribed_date, start_date, + end_date, notes, tags } = data; + const encrypted_data = await encryptJson({ + name, dosage, frequency, route, reason, instructions, + sideEffects: side_effects, prescribedBy: prescribed_by, + prescribedDate: prescribed_date, startDate: start_date, + endDate: end_date, notes, tags, + }, key); + const wire = await apiService.updateMedication(id, { + encrypted_data, + active: data.active, + }); + // Update the domain medication in the store. set((state) => ({ medications: state.medications.map((med) => - med.medication_id === id ? updated : med + med.medication_id === id + ? { ...med, ...data, updated_at: wire.updated_at } + : med, ), isLoading: false, })); @@ -451,9 +555,30 @@ export const useProfileStore = create()( error: null, 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 profile = await apiService.getProfile(); + const wire = await apiService.getProfile(); + // Decrypt the profile name. + let name = ''; + if (wire.name_data && wire.name_iv) { + try { + name = await decryptJson({ data: wire.name_data, iv: wire.name_iv }, key); + } catch { name = ''; } + } + const profile: Profile = { + profile_id: wire.profile_id, + user_id: wire.user_id, + name, + role: wire.role, + permissions: wire.permissions, + created_at: wire.created_at, + updated_at: wire.updated_at, + }; set({ profile, isLoading: false }); } catch (error: any) { set({ @@ -464,9 +589,28 @@ export const useProfileStore = create()( }, 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 profile = await apiService.updateProfileName(name); + 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({ @@ -489,9 +633,36 @@ export const useAppointmentStore = create()( error: null, loadAppointments: async (status) => { + 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 appointments = await apiService.getAppointments(status); + const wireAppts = await apiService.getAppointments(status); + const appointments = await Promise.all( + wireAppts.map(async (w) => { + const data = await decryptJson>(w.encrypted_data, key); + return { + id: w.id, + appointment_id: w.appointment_id, + user_id: w.user_id, + profile_id: w.profile_id, + title: (data.title as string) ?? '', + provider: (data.provider as string) ?? '', + appointment_type: (data.appointmentType as string) ?? '', + date_time: (data.dateTime as string) ?? '', + location: data.location as string | undefined, + duration_minutes: data.durationMinutes as number | undefined, + reason: data.reason as string | undefined, + notes: data.notes as string | undefined, + status: w.status, + created_at: w.created_at, + updated_at: w.updated_at, + } as Appointment; + }), + ); set({ appointments, isLoading: false }); } catch (error: any) { set({ @@ -502,11 +673,40 @@ export const useAppointmentStore = create()( }, createAppointment: async (data) => { + 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 appt = await apiService.createAppointment(data); + const d = data as any; + const { title, provider, appointment_type, date_time, location, + duration_minutes, reason, notes } = d; + const encrypted_data = await encryptJson({ + title, provider, appointmentType: appointment_type, + dateTime: date_time, location, durationMinutes: duration_minutes, + reason, notes, + }, key); + const wire = await apiService.createAppointment({ + profile_id: d.profile_id, + encrypted_data, + status: d.status, + }); + // Build the domain Appointment for the store. + const appointment: Appointment = { + appointment_id: wire.appointment_id, + id: wire.id, + user_id: wire.user_id, + profile_id: wire.profile_id, + title, provider, appointment_type, date_time, + location, duration_minutes, reason, notes, + status: wire.status, + created_at: wire.created_at, + updated_at: wire.updated_at, + }; set((state) => ({ - appointments: [...state.appointments, appt], + appointments: [...state.appointments, appointment], isLoading: false, })); } catch (error: any) { @@ -519,11 +719,29 @@ export const useAppointmentStore = create()( }, updateAppointment: async (id, data) => { + const key = getEncKey(); + if (!key) { + set({ error: 'No encryption key — cannot encrypt data' }); + throw new Error('No encryption key'); + } try { - const updated = await apiService.updateAppointment(id, data); + const d = data as any; + const { title, provider, appointment_type, date_time, location, + duration_minutes, reason, notes } = d; + const encrypted_data = await encryptJson({ + title, provider, appointmentType: appointment_type, + dateTime: date_time, location, durationMinutes: duration_minutes, + reason, notes, + }, key); + const wire = await apiService.updateAppointment(id, { + encrypted_data, + status: d.status, + }); set((state) => ({ appointments: state.appointments.map((a) => - a.appointment_id === id ? updated : a, + a.appointment_id === id + ? { ...a, ...data, status: wire.status, updated_at: wire.updated_at } + : a, ), })); } catch (error: any) { diff --git a/web/normogen-web/src/types/api.ts b/web/normogen-web/src/types/api.ts index 6ba868e..62918ad 100644 --- a/web/normogen-web/src/types/api.ts +++ b/web/normogen-web/src/types/api.ts @@ -110,6 +110,14 @@ export interface PillIdentification { custom_color?: string; } +// ---- Opaque encrypted blob wire types (zero-knowledge) ---- +export interface EncryptedFieldWire { + data: string; + iv: string; + auth_tag?: string; +} + +// ---- Medication: domain type (decrypted, used by UI) ---- export interface Medication { id?: string; medication_id?: string; @@ -129,39 +137,32 @@ export interface Medication { notes?: string; tags?: string[]; active: boolean; - pill_identification?: PillIdentification; created_at?: string; updated_at?: string; } -export interface CreateMedicationRequest { - name: string; - dosage: string; - frequency: string; - route: string; +// Medication wire response (opaque blob from the server) +export interface MedicationWireResponse { + id: string; + medication_id: string; + user_id: string; profile_id: string; - reason?: string; - instructions?: string; - side_effects?: string[]; - prescribed_by?: string; - prescribed_date?: string; - start_date?: string; - end_date?: string; - notes?: string; - tags?: string[]; - reminder_times?: string[]; - pill_identification?: PillIdentification; + active: boolean; + encrypted_data: EncryptedFieldWire; + created_at: string; + updated_at: string; +} + +// Medication create/update requests (send encrypted blob to the server) +export interface CreateMedicationRequest { + profile_id: string; + encrypted_data: EncryptedFieldWire; + active?: boolean; } export interface UpdateMedicationRequest { - name?: string; - dosage?: string; - frequency?: string; - start_date?: string; - end_date?: string; - instructions?: string; + encrypted_data?: EncryptedFieldWire; active?: boolean; - pill_identification?: PillIdentification; } // Drug Interaction Types (Phase 2.8) @@ -244,7 +245,7 @@ export interface LabResult { created_at?: string; } -// Appointment Types — match backend AppointmentResponse (snake_case). +// Appointment: domain type (decrypted, used by UI) export interface Appointment { id?: string; appointment_id: string; @@ -263,31 +264,41 @@ export interface Appointment { updated_at?: string; } -export interface CreateAppointmentRequest { - title: string; - provider: string; - appointment_type: string; - date_time: string; +// Appointment wire response (opaque blob) +export interface AppointmentWireResponse { + id: string; + appointment_id: string; + user_id: string; profile_id: string; - location?: string; - duration_minutes?: number; - reason?: string; - notes?: string; + status: string; + encrypted_data: EncryptedFieldWire; + created_at: string; + updated_at: string; +} + +export interface CreateAppointmentRequest { + profile_id: string; + encrypted_data: EncryptedFieldWire; status?: string; } export interface UpdateAppointmentRequest { - title?: string; - provider?: string; - appointment_type?: string; - date_time?: string; - location?: string; - duration_minutes?: number; - reason?: string; - notes?: string; + encrypted_data?: EncryptedFieldWire; status?: string; } +// Profile wire response (opaque encrypted name) +export interface ProfileWireResponse { + profile_id: string; + user_id: string; + name_data: string; + name_iv: string; + role: string; + permissions: string[]; + created_at: string; + updated_at: string; +} + // Dose Log Types — match backend MedicationDose (camelCase serialization). export interface DoseLog { id?: string;