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

@ -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<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 {
"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<String>,
pub duration_minutes: Option<i64>,
pub reason: Option<String>,
pub notes: Option<String>,
pub status: String,
pub encrypted_data: crate::models::medication::EncryptedFieldWire,
pub created_at: String,
pub updated_at: String,
}
impl std::convert::From<Appointment> 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<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")]
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<String>,
pub duration_minutes: Option<i64>,
pub reason: Option<String>,
pub notes: Option<String>,
/// Defaults to "upcoming" when omitted.
pub status: Option<String>,
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<String>,
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 encrypted_data: Option<crate::models::medication::EncryptedFieldWire>,
pub status: Option<String>,
}
@ -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<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 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::<AppointmentData>(&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<Option<Appointment>, Box<dyn std::error::Error>> {
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());
}
}