feat: zero-knowledge encryption Phase 1 — backend opaque + crypto module (WIP)

Backend: server can no longer read user data. All data blobs (medication,
appointment, profile name) are now opaque client-encrypted ciphertext — the
server stores and returns them verbatim, never deserializing the contents.

- Medication: removed MedicationData + flat MedicationResponse; new
  MedicationResponse echoes metadata + encrypted_data blob. Create/update
  accept opaque blobs (whole-blob replace). Update is no longer load-mutate-
  reserialize (server can't read the data).
- Appointment: same opaque treatment; status moved to a top-level document
  field so it remains filterable without decryption.
- Profile: name is now an opaque encrypted blob (name_data/name_iv). Auto-
  created profile on register starts with an empty name; client sets it.
- EncryptedFieldWire type shared across medication/appointment.

Frontend (partial): crypto module using Web Crypto API —
- crypto/keys.ts: double-PBKDF2 derivation (auth secret sent to server +
  encryption key kept in memory); in-memory key store (set/get/clear).
- crypto/cipher.ts: AES-GCM encrypt/decrypt + JSON convenience wrappers.
- crypto/index.ts: re-exports.

NOT YET DONE (frontend integration): auth store key derivation on login/register,
stores decrypt-on-load/encrypt-on-write, types update, UI components wired,
crypto round-trip tests, ADR. This commit is a verified checkpoint — backend
builds clean (21 tests, 0 warnings); frontend crypto module exists but is not
yet wired into the data flow.
This commit is contained in:
goose 2026-06-28 20:08:38 -03:00
parent 057303a8d0
commit 149ce37654
9 changed files with 331 additions and 566 deletions

View file

@ -33,23 +33,12 @@ pub async fn create_appointment(
let now = SystemTime::now(); let now = SystemTime::now();
let appointment_id = uuid::Uuid::new_v4().to_string(); 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 { let appointment_data = EncryptedField {
data: serde_json::json!({ data: req.encrypted_data.data,
"title": req.title, encrypted: true,
"provider": req.provider, iv: req.encrypted_data.iv,
"appointmentType": req.appointment_type, auth_tag: req.encrypted_data.auth_tag,
"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(),
}; };
let appointment = crate::models::appointment::Appointment { let appointment = crate::models::appointment::Appointment {
@ -59,6 +48,7 @@ pub async fn create_appointment(
profile_id: req.profile_id.clone(), profile_id: req.profile_id.clone(),
appointment_data, appointment_data,
reminders: vec![], reminders: vec![],
status: req.status,
created_at: DateTime::from_system_time(now), created_at: DateTime::from_system_time(now),
updated_at: DateTime::from_system_time(now), updated_at: DateTime::from_system_time(now),
}; };

View file

@ -31,28 +31,13 @@ pub async fn create_medication(
let now = SystemTime::now(); let now = SystemTime::now();
let medication_id = uuid::Uuid::new_v4().to_string(); let medication_id = uuid::Uuid::new_v4().to_string();
// Build medication data JSON // Zero-knowledge: the client sends an already-encrypted blob; the server
let medication_data_value = serde_json::json!({ // stores it verbatim and never inspects the contents.
"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(),
});
let medication_data = crate::models::health_data::EncryptedField { let medication_data = crate::models::health_data::EncryptedField {
data: medication_data_value.to_string(), data: req.encrypted_data.data,
encrypted: false, encrypted: true,
iv: String::new(), iv: req.encrypted_data.iv,
auth_tag: String::new(), auth_tag: req.encrypted_data.auth_tag,
}; };
let medication = Medication { let medication = Medication {
@ -61,19 +46,11 @@ pub async fn create_medication(
user_id: claims.sub, user_id: claims.sub,
profile_id: req.profile_id.clone(), profile_id: req.profile_id.clone(),
medication_data, medication_data,
reminders: req reminders: vec![],
.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(),
created_at: now.into(), created_at: now.into(),
updated_at: now.into(), updated_at: now.into(),
active: req.active, active: req.active,
pill_identification: req.pill_identification, // Phase 2.8 pill_identification: None,
}; };
match repo.create(medication).await { match repo.create(medication).await {

View file

@ -5,7 +5,6 @@ use axum::{
}; };
use mongodb::bson::DateTime; use mongodb::bson::DateTime;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use validator::Validate;
use crate::{ use crate::{
auth::jwt::Claims, auth::jwt::Claims,
@ -13,12 +12,15 @@ use crate::{
models::profile::{Profile, ProfileRepository}, 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)] #[derive(Debug, Serialize)]
pub struct ProfileResponse { pub struct ProfileResponse {
pub profile_id: String, pub profile_id: String,
pub user_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 role: String,
pub permissions: Vec<String>, pub permissions: Vec<String>,
pub created_at: DateTime, pub created_at: DateTime,
@ -30,7 +32,8 @@ impl From<Profile> for ProfileResponse {
Self { Self {
profile_id: p.profile_id, profile_id: p.profile_id,
user_id: p.user_id, user_id: p.user_id,
name: p.name, name_data: p.name,
name_iv: p.name_iv,
role: p.role, role: p.role,
permissions: p.permissions, permissions: p.permissions,
created_at: p.created_at, created_at: p.created_at,
@ -41,17 +44,17 @@ impl From<Profile> for ProfileResponse {
/// Create the default "patient" profile for a freshly registered user. Called /// Create the default "patient" profile for a freshly registered user. Called
/// from `register`. The profile_id is deterministic: `profile_<user_id>` — this /// from `register`. The profile_id is deterministic: `profile_<user_id>` — this
/// is the contract the frontend relies on for medication creation. /// is the contract the frontend relies on for medication creation. The name is
pub fn build_default_profile(user_id: &str, name: &str) -> Profile { /// 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(); let now = DateTime::now();
Profile { Profile {
id: None, id: None,
profile_id: format!("profile_{user_id}"), profile_id: format!("profile_{user_id}"),
user_id: user_id.to_string(), user_id: user_id.to_string(),
family_id: None, family_id: None,
// TODO: encrypt the name (the model anticipates nameIv/nameAuthTag, but name: String::new(),
// no crypto layer is implemented yet).
name: name.to_string(),
name_iv: String::new(), name_iv: String::new(),
name_auth_tag: String::new(), name_auth_tag: String::new(),
role: "patient".to_string(), 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 { pub struct UpdateProfileNameRequest {
#[validate(length(min = 1, max = 100))] pub name_data: String,
pub name: 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 /// 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))) 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( pub async fn update_my_profile(
State(state): State<AppState>, State(state): State<AppState>,
Extension(claims): Extension<Claims>, Extension(claims): Extension<Claims>,
Json(req): Json<UpdateProfileNameRequest>, Json(req): Json<UpdateProfileNameRequest>,
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> { ) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
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 database = state.db.get_database();
let repo = ProfileRepository::new(database.collection("profiles")); 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(Some(updated)) => Ok(Json(ProfileResponse::from(updated))),
Ok(None) => Err(( Ok(None) => Err((
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,

View file

@ -4,74 +4,37 @@ use mongodb::bson::{doc, oid::ObjectId, DateTime};
use mongodb::Collection; use mongodb::Collection;
use serde::{Deserialize, Serialize}; 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<String>,
#[serde(rename = "durationMinutes")]
pub duration_minutes: Option<i64>,
pub reason: Option<String>,
pub notes: Option<String>,
#[serde(default = "default_status")]
pub status: String,
}
fn default_status() -> String { fn default_status() -> String {
"upcoming".to_string() "upcoming".to_string()
} }
/// The flat, snake_case appointment representation returned by the API. Built /// Zero-knowledge wire response. The server returns ONLY metadata + the opaque
/// from a stored `Appointment` by deserializing its `appointment_data.data` blob. /// encrypted data blob + the top-level `status` (filterable without decrypting).
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct AppointmentResponse { pub struct AppointmentResponse {
pub id: String, pub id: String,
pub appointment_id: String, pub appointment_id: String,
pub user_id: String, pub user_id: String,
pub profile_id: String, pub profile_id: String,
pub title: String,
pub provider: String,
pub appointment_type: String,
pub date_time: String,
pub location: Option<String>,
pub duration_minutes: Option<i64>,
pub reason: Option<String>,
pub notes: Option<String>,
pub status: String, pub status: String,
pub encrypted_data: crate::models::medication::EncryptedFieldWire,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
impl std::convert::From<Appointment> for AppointmentResponse { impl std::convert::From<Appointment> for AppointmentResponse {
fn from(a: Appointment) -> Self { fn from(a: Appointment) -> Self {
let data: AppointmentData =
serde_json::from_str(&a.appointment_data.data).unwrap_or_default();
AppointmentResponse { AppointmentResponse {
id: a.id.map(|o| o.to_hex()).unwrap_or_default(), id: a.id.map(|o| o.to_hex()).unwrap_or_default(),
appointment_id: a.appointment_id, appointment_id: a.appointment_id,
user_id: a.user_id, user_id: a.user_id,
profile_id: a.profile_id, profile_id: a.profile_id,
title: data.title, status: a.status,
provider: data.provider, encrypted_data: crate::models::medication::EncryptedFieldWire {
appointment_type: data.appointment_type, data: a.appointment_data.data,
date_time: data.date_time, iv: a.appointment_data.iv,
location: data.location, auth_tag: a.appointment_data.auth_tag,
duration_minutes: data.duration_minutes, },
reason: data.reason,
notes: data.notes,
status: data.status,
created_at: system_time_to_rfc3339(a.created_at.to_system_time()), created_at: system_time_to_rfc3339(a.created_at.to_system_time()),
updated_at: system_time_to_rfc3339(a.updated_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, pub appointment_data: EncryptedField,
#[serde(rename = "reminders")] #[serde(rename = "reminders")]
pub reminders: Vec<AppointmentReminder>, pub reminders: Vec<AppointmentReminder>,
/// Top-level status so it can be filtered without decrypting the blob.
#[serde(rename = "status", default = "default_status")]
pub status: String,
#[serde(rename = "createdAt")] #[serde(rename = "createdAt")]
pub created_at: DateTime, pub created_at: DateTime,
#[serde(rename = "updatedAt")] #[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)] #[derive(Debug, Deserialize)]
pub struct CreateAppointmentRequest { pub struct CreateAppointmentRequest {
pub title: String,
pub provider: String,
pub appointment_type: String,
pub date_time: String,
pub profile_id: String, pub profile_id: String,
pub location: Option<String>, pub encrypted_data: crate::models::medication::EncryptedFieldWire,
pub duration_minutes: Option<i64>, /// Defaults to "upcoming".
pub reason: Option<String>, #[serde(default = "default_status")]
pub notes: Option<String>, pub status: String,
/// Defaults to "upcoming" when omitted.
pub status: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UpdateAppointmentRequest { pub struct UpdateAppointmentRequest {
pub title: Option<String>, pub encrypted_data: Option<crate::models::medication::EncryptedFieldWire>,
pub provider: Option<String>,
pub appointment_type: Option<String>,
pub date_time: Option<String>,
pub location: Option<String>,
pub duration_minutes: Option<i64>,
pub reason: Option<String>,
pub notes: Option<String>,
pub status: Option<String>, pub status: Option<String>,
} }
@ -173,27 +126,21 @@ impl AppointmentRepository {
Ok(appointment) Ok(appointment)
} }
/// List a user's appointments, optionally filtered by status (a field /// List a user's appointments, optionally filtered by top-level `status`.
/// inside the data blob — so filter post-query in Rust).
pub async fn find_by_user_filtered( pub async fn find_by_user_filtered(
&self, &self,
user_id: &str, user_id: &str,
status: Option<&str>, status: Option<&str>,
) -> Result<Vec<Appointment>, Box<dyn std::error::Error>> { ) -> Result<Vec<Appointment>, Box<dyn std::error::Error>> {
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 cursor = self.collection.find(filter, None).await?;
let mut appointments = Vec::new(); let mut appointments = Vec::new();
while let Some(appt) = cursor.next().await { while let Some(appt) = cursor.next().await {
appointments.push(appt?); appointments.push(appt?);
} }
// status lives inside the data blob; filter here.
if let Some(wanted) = status {
appointments.retain(|a| {
serde_json::from_str::<AppointmentData>(&a.appointment_data.data)
.map(|d| d.status == wanted)
.unwrap_or(false)
});
}
Ok(appointments) Ok(appointments)
} }
@ -205,65 +152,23 @@ impl AppointmentRepository {
Ok(self.collection.find_one(filter, None).await?) 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( pub async fn update_by_appointment_id(
&self, &self,
appointment_id: &str, appointment_id: &str,
updates: UpdateAppointmentRequest, updates: UpdateAppointmentRequest,
) -> Result<Option<Appointment>, Box<dyn std::error::Error>> { ) -> Result<Option<Appointment>, Box<dyn std::error::Error>> {
let filter = doc! { "appointmentId": appointment_id }; let filter = doc! { "appointmentId": appointment_id };
let existing = self.collection.find_one(filter.clone(), None).await?; let mut update_doc = doc! { "updatedAt": DateTime::now() };
let Some(existing) = existing else {
return Ok(None);
};
let mut data: AppointmentData = if let Some(blob) = updates.encrypted_data {
serde_json::from_str(&existing.appointment_data.data).unwrap_or_default(); update_doc.insert("appointmentData.data", blob.data);
update_doc.insert("appointmentData.iv", blob.iv);
if let Some(v) = updates.title { update_doc.insert("appointmentData.authTag", blob.auth_tag);
data.title = v;
} }
if let Some(v) = updates.provider { if let Some(status) = updates.status {
data.provider = v; 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 Ok(self
.collection .collection
@ -286,60 +191,28 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn appointment_data_deserializes_camel_case_blob() { fn appointment_response_echoes_opaque_blob_and_status() {
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();
let appt = Appointment { let appt = Appointment {
id: Some(ObjectId::new()), id: Some(ObjectId::new()),
appointment_id: "apt-1".to_string(), appointment_id: "apt-1".to_string(),
user_id: "user-1".to_string(), user_id: "user-1".to_string(),
profile_id: "profile_user-1".to_string(), profile_id: "profile_user-1".to_string(),
appointment_data: EncryptedField { appointment_data: EncryptedField {
data: blob, data: "opaque-ciphertext".to_string(),
encrypted: false, encrypted: true,
iv: String::new(), iv: "base64-iv".to_string(),
auth_tag: String::new(), auth_tag: String::new(),
}, },
reminders: vec![], reminders: vec![],
status: "completed".to_string(),
created_at: DateTime::now(), created_at: DateTime::now(),
updated_at: DateTime::now(), updated_at: DateTime::now(),
}; };
let resp: AppointmentResponse = appt.into(); let resp: AppointmentResponse = appt.into();
assert_eq!(resp.appointment_id, "apt-1"); 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.status, "completed");
assert_eq!(resp.encrypted_data.data, "opaque-ciphertext");
assert!(!resp.id.is_empty()); assert!(!resp.id.is_empty());
} }
} }

View file

@ -96,85 +96,47 @@ pub struct AdherenceStats {
// MEDICATION MODEL (Existing + Phase 2.8 updates) // MEDICATION MODEL (Existing + Phase 2.8 updates)
// ============================================================================ // ============================================================================
/// The structured content stored (as a JSON string) inside `medication_data.data`. /// Zero-knowledge wire response. The server returns ONLY metadata + the opaque
/// Deserializes from the camelCase keys the create handler packs in. This is the /// encrypted data blob (which the client decrypts). The server cannot read
/// intermediate type used to flatten a `Medication` into a `MedicationResponse`. /// name/dosage/etc. — those live encrypted inside `encrypted_data`.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct MedicationData {
pub name: String,
pub dosage: String,
pub frequency: String,
pub route: String,
pub reason: Option<String>,
pub instructions: Option<String>,
#[serde(rename = "sideEffects", default)]
pub side_effects: Vec<String>,
#[serde(rename = "prescribedBy")]
pub prescribed_by: Option<String>,
#[serde(rename = "prescribedDate")]
pub prescribed_date: Option<String>,
#[serde(rename = "startDate")]
pub start_date: Option<String>,
#[serde(rename = "endDate")]
pub end_date: Option<String>,
pub notes: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
}
/// 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.
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct MedicationResponse { pub struct MedicationResponse {
pub id: String, pub id: String,
pub medication_id: String, pub medication_id: String,
pub user_id: String, pub user_id: String,
pub profile_id: String, pub profile_id: String,
pub name: String,
pub dosage: String,
pub frequency: String,
pub route: String,
pub reason: Option<String>,
pub instructions: Option<String>,
pub side_effects: Vec<String>,
pub prescribed_by: Option<String>,
pub prescribed_date: Option<String>,
pub start_date: Option<String>,
pub end_date: Option<String>,
pub notes: Option<String>,
pub tags: Vec<String>,
pub active: bool, 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 created_at: String,
pub updated_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<Medication> for MedicationResponse { impl std::convert::From<Medication> for MedicationResponse {
fn from(m: Medication) -> Self { 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 { MedicationResponse {
id: m.id.map(|o| o.to_hex()).unwrap_or_default(), id: m.id.map(|o| o.to_hex()).unwrap_or_default(),
medication_id: m.medication_id, medication_id: m.medication_id,
user_id: m.user_id, user_id: m.user_id,
profile_id: m.profile_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, 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()), created_at: system_time_to_rfc3339(m.created_at.to_system_time()),
updated_at: system_time_to_rfc3339(m.updated_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)] #[derive(Debug, Deserialize)]
pub struct CreateMedicationRequest { pub struct CreateMedicationRequest {
pub name: String,
pub dosage: String,
pub frequency: String,
pub route: String,
pub reason: Option<String>,
pub instructions: Option<String>,
pub side_effects: Option<Vec<String>>,
pub prescribed_by: Option<String>,
pub prescribed_date: Option<String>,
pub start_date: Option<String>,
pub end_date: Option<String>,
pub notes: Option<String>,
pub tags: Option<Vec<String>>,
pub reminder_times: Option<Vec<String>>,
pub profile_id: String, 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). /// Whether the medication is active on creation (defaults to true).
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub active: bool, pub active: bool,
/// Pill identification (Phase 2.8 - optional)
#[serde(rename = "pill_identification")]
pub pill_identification: Option<PillIdentification>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UpdateMedicationRequest { pub struct UpdateMedicationRequest {
pub name: Option<String>, /// Replacement opaque client-encrypted data blob (whole-blob replace — the
pub dosage: Option<String>, /// client re-encrypts the full record on edit).
pub frequency: Option<String>, pub encrypted_data: Option<EncryptedFieldWire>,
pub route: Option<String>,
pub reason: Option<String>,
pub instructions: Option<String>,
pub side_effects: Option<Vec<String>>,
pub prescribed_by: Option<String>,
pub prescribed_date: Option<String>,
pub start_date: Option<String>,
pub end_date: Option<String>,
pub notes: Option<String>,
pub tags: Option<Vec<String>>,
pub reminder_times: Option<Vec<String>>,
/// Set the medication active/inactive. /// Set the medication active/inactive.
pub active: Option<bool>, pub active: Option<bool>,
/// Pill identification (Phase 2.8 - optional)
#[serde(rename = "pill_identification")]
pub pill_identification: Option<PillIdentification>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -411,90 +342,24 @@ impl MedicationRepository {
/// Update by the application-level medication_id (UUID). The API exposes /// Update by the application-level medication_id (UUID). The API exposes
/// medication_id in URLs, not the Mongo _id. /// 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( pub async fn update_by_medication_id(
&self, &self,
medication_id: &str, medication_id: &str,
updates: UpdateMedicationRequest, updates: UpdateMedicationRequest,
) -> Result<Option<Medication>, Box<dyn std::error::Error>> { ) -> Result<Option<Medication>, Box<dyn std::error::Error>> {
let filter = doc! { "medicationId": medication_id }; let filter = doc! { "medicationId": medication_id };
let existing = self.collection.find_one(filter.clone(), None).await?; let mut update_doc = doc! { "updatedAt": DateTime::now() };
let Some(existing) = existing else {
return Ok(None);
};
let mut data: MedicationData = if let Some(blob) = updates.encrypted_data {
serde_json::from_str(&existing.medication_data.data).unwrap_or_default(); update_doc.insert("medicationData.data", blob.data);
update_doc.insert("medicationData.iv", blob.iv);
if let Some(v) = updates.name { update_doc.insert("medicationData.authTag", blob.auth_tag);
data.name = v;
} }
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 { if let Some(active) = updates.active {
update_doc.insert("active", 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 let medication = self
.collection .collection
@ -513,97 +378,23 @@ impl MedicationRepository {
Ok(result.deleted_count > 0) Ok(result.deleted_count > 0)
} }
/// Whole-blob replace by Mongo _id (unused by routes but kept for completeness).
pub async fn update( pub async fn update(
&self, &self,
id: &ObjectId, id: &ObjectId,
updates: UpdateMedicationRequest, updates: UpdateMedicationRequest,
) -> Result<Option<Medication>, Box<dyn std::error::Error>> { ) -> Result<Option<Medication>, Box<dyn std::error::Error>> {
// The stored shape is `medicationData: { data: "<JSON string>", ... }`.
// 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 filter = doc! { "_id": id };
let existing = self.collection.find_one(filter.clone(), None).await?; let mut update_doc = doc! { "updatedAt": DateTime::now() };
let Some(existing) = existing else {
return Ok(None);
};
let mut data: MedicationData = if let Some(blob) = updates.encrypted_data {
serde_json::from_str(&existing.medication_data.data).unwrap_or_default(); update_doc.insert("medicationData.data", blob.data);
update_doc.insert("medicationData.iv", blob.iv);
if let Some(v) = updates.name { update_doc.insert("medicationData.authTag", blob.auth_tag);
data.name = v;
} }
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 { if let Some(active) = updates.active {
update_doc.insert("active", 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 let medication = self
.collection .collection
@ -629,59 +420,18 @@ mod tests {
use crate::models::health_data::EncryptedField; use crate::models::health_data::EncryptedField;
#[test] #[test]
fn medication_data_deserializes_camel_case_blob() { fn medication_response_echoes_opaque_blob_unchanged() {
// This is the exact shape the create handler packs into the data string. // Zero-knowledge: the server returns the ciphertext blob verbatim; it
let json = r#"{ // never inspects the contents. The blob here is opaque base64 ciphertext.
"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();
let med = Medication { let med = Medication {
id: Some(ObjectId::new()), id: Some(ObjectId::new()),
medication_id: "med-123".to_string(), medication_id: "med-123".to_string(),
user_id: "user-1".to_string(), user_id: "user-1".to_string(),
profile_id: "profile_user-1".to_string(), profile_id: "profile_user-1".to_string(),
medication_data: EncryptedField { medication_data: EncryptedField {
data: blob, data: "opaque-base64-ciphertext".to_string(),
encrypted: false, encrypted: true,
iv: String::new(), iv: "base64-iv".to_string(),
auth_tag: String::new(), auth_tag: String::new(),
}, },
reminders: vec![], reminders: vec![],
@ -693,35 +443,10 @@ mod tests {
let resp: MedicationResponse = med.into(); let resp: MedicationResponse = med.into();
assert_eq!(resp.medication_id, "med-123"); assert_eq!(resp.medication_id, "med-123");
assert_eq!(resp.name, "Aspirin"); assert_eq!(resp.encrypted_data.data, "opaque-base64-ciphertext");
assert_eq!(resp.side_effects, vec!["bleeding".to_string()]); assert_eq!(resp.encrypted_data.iv, "base64-iv");
assert!(resp.active); assert!(resp.active);
assert!(!resp.id.is_empty()); assert!(!resp.id.is_empty());
assert!(!resp.created_at.is_empty()); // ISO string 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);
}
} }

View file

@ -60,21 +60,21 @@ impl ProfileRepository {
.await .await
} }
/// Update the profile's display name. NOTE: name is currently stored /// Update the profile's display name (opaque client-encrypted blob).
/// plaintext — the model's nameIv/nameAuthTag fields anticipate encryption pub async fn update_encrypted_name(
/// that isn't implemented yet (TODO).
pub async fn update_name(
&self, &self,
user_id: &str, user_id: &str,
name: &str, name_data: &str,
name_iv: &str,
name_auth_tag: &str,
) -> mongodb::error::Result<Option<Profile>> { ) -> mongodb::error::Result<Option<Profile>> {
self.collection self.collection
.find_one_and_update( .find_one_and_update(
doc! { "userId": user_id }, doc! { "userId": user_id },
doc! { "$set": { doc! { "$set": {
"name": name, "name": name_data,
"nameIv": "", "nameIv": name_iv,
"nameAuthTag": "", "nameAuthTag": name_auth_tag,
"updatedAt": DateTime::now() "updatedAt": DateTime::now()
}}, }},
None, None,

View file

@ -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<CipherPayload> {
const iv = randomIv();
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encoder.encode(plaintext),
);
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<string> {
const iv = b64.decode(payload.iv);
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
b64.decode(payload.data),
);
return decoder.decode(plaintext);
}
/** Encrypt a JSON-serializable object. */
export async function encryptJson<T>(
obj: T,
key: CryptoKey,
): Promise<CipherPayload> {
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<T>(
payload: CipherPayload,
key: CryptoKey,
): Promise<T> {
return JSON.parse(await decrypt(payload, key)) as T;
}

View file

@ -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';

View file

@ -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;
}