Compare commits

..

No commits in common. "main" and "feat/3-phase-a2-per-profile-deks" have entirely different histories.

35 changed files with 1997 additions and 4024 deletions

View file

@ -35,10 +35,7 @@ pub fn build_app(state: AppState) -> Router {
"/api/auth/recover-password",
post(handlers::recover_password),
)
.route("/api/auth/recovery-info", get(handlers::recovery_info))
// Recipient identity public key lookup (Phase B). Public keys are not
// secret, so this is unauthenticated — mirrors recovery-info.
.route("/api/users/public-key", get(handlers::get_public_key));
.route("/api/auth/recovery-info", get(handlers::recovery_info));
// Build protected routes (auth required)
let protected_routes = Router::new()
@ -50,24 +47,19 @@ pub fn build_app(state: AppState) -> Router {
// User settings
.route("/api/users/me/settings", get(handlers::get_settings))
.route("/api/users/me/settings", put(handlers::update_settings))
// Profile management (Phase A2 multi-profile + Phase B sharing).
// GET /api/profiles/:id is share-aware (admits recipients); the other
// profile ops remain owner-only.
// Share management
.route("/api/shares", post(handlers::create_share))
.route("/api/shares", get(handlers::list_shares))
.route("/api/shares/:id", put(handlers::update_share))
.route("/api/shares/:id", delete(handlers::delete_share))
// Permission checking
.route("/api/permissions/check", post(handlers::check_permission))
// Profile management (Phase A2: multi-profile + per-profile DEKs)
.route("/api/profiles", get(handlers::list_profiles))
.route("/api/profiles", post(handlers::create_profile))
.route("/api/profiles/shared-with-me", get(handlers::list_shared_with_me))
.route("/api/profiles/:id", get(handlers::get_profile_shared_aware))
.route("/api/profiles/:id", get(handlers::get_profile))
.route("/api/profiles/:id", put(handlers::update_profile))
.route("/api/profiles/:id", delete(handlers::delete_profile))
// Profile sharing (Phase B): owner manages shares; recipients read via
// the gate inside the data handlers.
.route(
"/api/profiles/:id/shares",
get(handlers::list_shares).post(handlers::create_share),
)
.route("/api/profiles/:id/shares/:recipient", delete(handlers::delete_share))
// Hard revoke / rotate the profile DEK (Phase C). Owner-only.
.route("/api/profiles/:id/rekey", post(handlers::rekey_profile))
// Session management (Phase 2.6)
.route("/api/sessions", get(handlers::get_sessions))
.route("/api/sessions/:id", delete(handlers::revoke_session))

View file

@ -0,0 +1,22 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessClaims {
pub sub: String,
pub email: String,
pub family_id: Option<String>,
pub permissions: Vec<String>,
pub token_type: String,
pub iat: i64,
pub exp: i64,
pub jti: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RefreshClaims {
pub sub: String,
pub token_type: String,
pub iat: i64,
pub exp: i64,
pub jti: String,
}

View file

@ -98,30 +98,15 @@ impl DatabaseInitializer {
println!("✓ Created appointments collection");
}
// Create profile_shares collection and indexes (Phase B — replaces the
// legacy shares collection). Lookups are by (profileId, recipientUserId)
// and by recipientUserId alone (for /profiles/shared-with-me).
// Create shares collection and index
{
let collection: Collection<mongodb::bson::Document> =
self.db.collection("profile_shares");
let collection: Collection<mongodb::bson::Document> = self.db.collection("shares");
let pair_index = IndexModel::builder()
.keys(doc! { "profileId": 1, "recipientUserId": 1 })
.build();
match collection.create_index(pair_index, None).await {
Ok(_) => println!("✓ Created index on profile_shares (profileId, recipientUserId)"),
Err(e) => println!("Warning: Failed to create profile_shares pair index: {}", e),
}
let index = IndexModel::builder().keys(doc! { "familyId": 1 }).build();
let recipient_index = IndexModel::builder()
.keys(doc! { "recipientUserId": 1 })
.build();
match collection.create_index(recipient_index, None).await {
Ok(_) => println!("✓ Created index on profile_shares.recipientUserId"),
Err(e) => println!(
"Warning: Failed to create profile_shares recipient index: {}",
e
),
match collection.create_index(index, None).await {
Ok(_) => println!("✓ Created index on shares.familyId"),
Err(e) => println!("Warning: Failed to create index on shares.familyId: {}", e),
}
}

View file

@ -5,6 +5,8 @@ use std::time::Duration;
use crate::models::{
medication::{Medication, MedicationDose, MedicationRepository, UpdateMedicationRequest},
permission::Permission,
share::{Share, ShareRepository},
user::{User, UserRepository},
};
@ -12,6 +14,7 @@ use crate::models::{
pub struct MongoDb {
database: Database,
pub users: Collection<User>,
pub shares: Collection<Share>,
pub medications: Collection<Medication>,
pub medication_doses: Collection<MedicationDose>,
}
@ -59,6 +62,7 @@ impl MongoDb {
return Ok(Self {
users: database.collection("users"),
shares: database.collection("shares"),
medications: database.collection("medications"),
medication_doses: database.collection("medication_doses"),
database,
@ -93,6 +97,7 @@ impl MongoDb {
return Ok(Self {
users: database.collection("users"),
shares: database.collection("shares"),
medications: database.collection("medications"),
medication_doses: database.collection("medication_doses"),
database,
@ -108,6 +113,7 @@ impl MongoDb {
Ok(Self {
users: database.collection("users"),
shares: database.collection("shares"),
medications: database.collection("medications"),
medication_doses: database.collection("medication_doses"),
database,
@ -171,6 +177,100 @@ impl MongoDb {
Ok(())
}
// ===== Share Methods =====
pub async fn create_share(&self, share: &Share) -> Result<Option<ObjectId>> {
let repo = ShareRepository::new(self.shares.clone());
Ok(repo.create(share).await?)
}
pub async fn get_share(&self, id: &str) -> Result<Option<Share>> {
let object_id = ObjectId::parse_str(id)?;
let repo = ShareRepository::new(self.shares.clone());
Ok(repo.find_by_id(&object_id).await?)
}
pub async fn list_shares_for_user(&self, user_id: &str) -> Result<Vec<Share>> {
let object_id = ObjectId::parse_str(user_id)?;
let repo = ShareRepository::new(self.shares.clone());
Ok(repo.find_by_target(&object_id).await?)
}
pub async fn update_share(&self, share: &Share) -> Result<()> {
let repo = ShareRepository::new(self.shares.clone());
repo.update(share).await?;
Ok(())
}
pub async fn delete_share(&self, id: &str) -> Result<()> {
let object_id = ObjectId::parse_str(id)?;
let repo = ShareRepository::new(self.shares.clone());
repo.delete(&object_id).await?;
Ok(())
}
// ===== Permission Methods =====
pub async fn check_user_permission(
&self,
user_id: &str,
resource_type: &str,
resource_id: &str,
permission: &str,
) -> Result<bool> {
let user_oid = ObjectId::parse_str(user_id)?;
let resource_oid = ObjectId::parse_str(resource_id)?;
let repo = ShareRepository::new(self.shares.clone());
let shares = repo.find_by_target(&user_oid).await?;
for share in shares {
if share.resource_type == resource_type
&& share.resource_id.as_ref() == Some(&resource_oid)
&& share.active
&& !share.is_expired()
{
// Check if share has the required permission
let perm = match permission.to_lowercase().as_str() {
"read" => Permission::Read,
"write" => Permission::Write,
"delete" => Permission::Delete,
"share" => Permission::Share,
"admin" => Permission::Admin,
_ => return Ok(false),
};
if share.has_permission(&perm) {
return Ok(true);
}
}
}
Ok(false)
}
/// Check permission using a simplified interface
pub async fn check_permission(
&self,
user_id: &str,
resource_id: &str,
permission: &str,
) -> Result<bool> {
// For now, check all resource types
let resource_types = ["profiles", "health_data", "lab_results", "medications"];
for resource_type in resource_types {
if self
.check_user_permission(user_id, resource_type, resource_id, permission)
.await?
{
return Ok(true);
}
}
Ok(false)
}
// ===== Medication Methods (Fixed for Phase 2.8) =====
pub async fn create_medication(&self, medication: &Medication) -> Result<Option<ObjectId>> {

View file

@ -65,175 +65,68 @@ pub async fn list_appointments(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Query(query): Query<AppointmentListQuery>,
) -> Result<Json<Vec<AppointmentResponse>>, (StatusCode, Json<serde_json::Value>)> {
) -> Result<Json<Vec<AppointmentResponse>>, StatusCode> {
let database = state.db.get_database();
let repo = AppointmentRepository::new(database.collection("appointments"));
// Resolve the effective owner. If a profile_id is specified, prefer direct
// ownership (the caller's own data for that profile); otherwise fall through
// to the share-gate (admits an active share recipient). profile_id is a
// free-form string at create time and may not map to a Profile document.
let owner = match &query.profile_id {
Some(pid) => {
let has_own = repo
.find_by_user_filtered(&claims.sub, None, Some(pid))
.await
.map(|v| !v.is_empty())
.unwrap_or(false);
if has_own {
claims.sub.clone()
} else {
crate::handlers::profile_share::authorize_profile_read(&state, &claims, pid).await?
}
}
None => claims.sub.clone(),
};
match repo
.find_by_user_filtered(&owner, query.status.as_deref(), query.profile_id.as_deref())
.find_by_user_filtered(
&claims.sub,
query.status.as_deref(),
query.profile_id.as_deref(),
)
.await
{
Ok(appointments) => {
let resp: Vec<AppointmentResponse> = appointments.into_iter().map(Into::into).collect();
Ok(Json(resp))
}
Err(e) => {
tracing::error!("list_appointments failed: {}", e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
))
}
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
pub async fn get_appointment(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Extension(_claims): Extension<Claims>,
Path(id): Path<String>,
) -> Result<Json<AppointmentResponse>, (StatusCode, Json<serde_json::Value>)> {
) -> Result<Json<AppointmentResponse>, StatusCode> {
let database = state.db.get_database();
let repo = AppointmentRepository::new(database.collection("appointments"));
let appt = match repo.find_by_appointment_id(&id).await {
Ok(Some(a)) => a,
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "not found" })),
));
match repo.find_by_appointment_id(&id).await {
Ok(Some(appt)) => Ok(Json(appt.into())),
Ok(None) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
Err(e) => {
tracing::error!("get_appointment failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
// Authorization: direct owner (user_id == caller) OR share-gate on the
// appointment's profile (admits an active share recipient).
if appt.user_id != claims.sub {
crate::handlers::profile_share::authorize_profile_read(&state, &claims, &appt.profile_id)
.await?;
}
Ok(Json(appt.into()))
}
pub async fn update_appointment(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Extension(_claims): Extension<Claims>,
Path(id): Path<String>,
Json(req): Json<UpdateAppointmentRequest>,
) -> Result<Json<AppointmentResponse>, (StatusCode, Json<serde_json::Value>)> {
) -> Result<Json<AppointmentResponse>, StatusCode> {
let database = state.db.get_database();
let repo = AppointmentRepository::new(database.collection("appointments"));
// Authorization: owner-only (recipients are read-only). 404 on mismatch to
// avoid leaking existence.
let existing = match repo.find_by_appointment_id(&id).await {
Ok(Some(a)) => a,
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "not found" })),
));
}
Err(e) => {
tracing::error!("update_appointment lookup failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
if existing.user_id != claims.sub {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "not found" })),
));
}
match repo.update_by_appointment_id(&id, req).await {
Ok(Some(appt)) => Ok(Json(appt.into())),
Ok(None) => Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "not found" })),
)),
Err(e) => {
tracing::error!("update_appointment failed: {}", e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
))
}
Ok(None) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
pub async fn delete_appointment(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Extension(_claims): Extension<Claims>,
Path(id): Path<String>,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
) -> Result<StatusCode, StatusCode> {
let database = state.db.get_database();
let repo = AppointmentRepository::new(database.collection("appointments"));
// Authorization: owner-only.
let existing = match repo.find_by_appointment_id(&id).await {
Ok(Some(a)) => a,
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "not found" })),
));
}
Err(e) => {
tracing::error!("delete_appointment lookup failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
if existing.user_id != claims.sub {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "not found" })),
));
}
match repo.delete_by_appointment_id(&id).await {
Ok(true) => Ok(StatusCode::NO_CONTENT),
Ok(false) => Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "not found" })),
)),
Err(e) => {
tracing::error!("delete_appointment failed: {}", e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
))
}
Ok(false) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}

View file

@ -90,32 +90,8 @@ pub async fn list_health_stats(
.into_response()
}
};
// Resolve the effective owner. If a profile_id is specified, prefer direct
// ownership (the caller's own data for that profile); otherwise fall through
// to the share-gate (admits an active share recipient). profile_id is a
// free-form string at create time and may not map to a Profile document.
let owner = match &query.profile_id {
Some(pid) => {
let has_own = repo
.find_by_user_filtered(&claims.sub, Some(pid))
.await
.map(|v| !v.is_empty())
.unwrap_or(false);
if has_own {
claims.sub.clone()
} else {
match crate::handlers::profile_share::authorize_profile_read(&state, &claims, pid)
.await
{
Ok(o) => o,
Err(resp) => return resp.into_response(),
}
}
}
None => claims.sub.clone(),
};
match repo
.find_by_user_filtered(&owner, query.profile_id.as_deref())
.find_by_user_filtered(&claims.sub, query.profile_id.as_deref())
.await
{
Ok(stats) => {
@ -158,21 +134,10 @@ pub async fn get_health_stat(
match repo.find_by_id(&object_id).await {
Ok(Some(stat)) => {
// Authorization: direct owner (user_id == caller) OR share-gate on
// the stat's profile (admits an active share recipient).
if stat.user_id == claims.sub {
return (StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response();
}
match crate::handlers::profile_share::authorize_profile_read(
&state,
&claims,
&stat.profile_id,
)
.await
{
Ok(_) => (StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response(),
Err(resp) => resp.into_response(),
if stat.user_id != claims.sub {
return (StatusCode::FORBIDDEN, "Access denied").into_response();
}
(StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,

View file

@ -64,193 +64,70 @@ pub async fn list_medications(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Query(query): Query<ListMedicationsQuery>,
) -> Result<Json<Vec<MedicationResponse>>, (StatusCode, Json<serde_json::Value>)> {
) -> Result<Json<Vec<MedicationResponse>>, StatusCode> {
let database = state.db.get_database();
let repo = MedicationRepository::new(database.collection("medications"));
// Resolve the effective owner. If the caller specifies a profile_id, prefer
// direct ownership (the caller's own data for that profile); if they don't
// own it, fall through to the share-gate (admits an active share recipient
// and returns the profile's owner userId to query as). Without a profile_id,
// return the caller's own data across all their profiles.
//
// The direct-ownership-first check matters because a medication's profile_id
// is a free-form string at create time and may not map to a Profile document
// (legacy/test data) — the gate would 404 on those even for the real owner.
let owner = match &query.profile_id {
Some(pid) => {
let has_own = repo
.find_by_user_and_profile(&claims.sub, pid)
.await
.map(|v| !v.is_empty())
.unwrap_or(false);
if has_own {
claims.sub.clone()
} else {
crate::handlers::profile_share::authorize_profile_read(&state, &claims, pid).await?
}
}
None => claims.sub.clone(),
};
// Honor the active filter (and optional profile_id) when provided.
match repo
.find_by_user_filtered(&owner, query.active, query.profile_id.as_deref())
.find_by_user_filtered(&claims.sub, query.active, query.profile_id.as_deref())
.await
{
Ok(medications) => {
let resp: Vec<MedicationResponse> = medications.into_iter().map(Into::into).collect();
Ok(Json(resp))
}
Err(e) => {
tracing::error!("list_medications failed: {}", e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
))
}
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
pub async fn get_medication(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Extension(_claims): Extension<Claims>,
Path(id): Path<String>,
) -> Result<Json<MedicationResponse>, (StatusCode, Json<serde_json::Value>)> {
) -> Result<Json<MedicationResponse>, StatusCode> {
let database = state.db.get_database();
let repo = MedicationRepository::new(database.collection("medications"));
// The path param is the application-level medication_id (a UUID), not the
// Mongo _id, so look it up directly instead of parsing as ObjectId.
let medication = match repo.find_by_medication_id(&id).await {
Ok(Some(m)) => m,
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "not found" })),
));
match repo.find_by_medication_id(&id).await {
Ok(Some(medication)) => Ok(Json(medication.into())),
Ok(None) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
Err(e) => {
tracing::error!("get_medication failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
// Authorization: admit the direct owner (user_id == caller) OR, if not,
// fall through to the share-gate on the medication's profile (admits an
// active share recipient). The direct-owner check preserves the original
// ownership model for medications whose profile_id may not map to a real
// Profile document (e.g. legacy/test data).
if medication.user_id != claims.sub {
crate::handlers::profile_share::authorize_profile_read(
&state,
&claims,
&medication.profile_id,
)
.await?;
}
Ok(Json(medication.into()))
}
pub async fn update_medication(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Extension(_claims): Extension<Claims>,
Path(id): Path<String>,
Json(req): Json<UpdateMedicationRequest>,
) -> Result<Json<MedicationResponse>, (StatusCode, Json<serde_json::Value>)> {
) -> Result<Json<MedicationResponse>, StatusCode> {
let database = state.db.get_database();
let repo = MedicationRepository::new(database.collection("medications"));
// Authorization: look up first, confirm the caller owns the medication
// (writes are owner-only — recipients are read-only per the ADR). Use 404
// (not 403) on a mismatch to avoid leaking the existence of other users'
// records.
let existing = match repo.find_by_medication_id(&id).await {
Ok(Some(m)) => m,
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "not found" })),
));
}
Err(e) => {
tracing::error!("update_medication lookup failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
if existing.user_id != claims.sub {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "not found" })),
));
}
// Look up by medication_id (UUID) — the path param is not a Mongo ObjectId.
match repo.update_by_medication_id(&id, req).await {
Ok(Some(medication)) => Ok(Json(medication.into())),
Ok(None) => Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "not found" })),
)),
Err(e) => {
tracing::error!("update_medication failed: {}", e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
))
}
Ok(None) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
pub async fn delete_medication(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Extension(_claims): Extension<Claims>,
Path(id): Path<String>,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
) -> Result<StatusCode, StatusCode> {
let database = state.db.get_database();
let repo = MedicationRepository::new(database.collection("medications"));
// Authorization: owner-only (see update_medication). Look up first to
// avoid leaking existence via the deleted_count.
let existing = match repo.find_by_medication_id(&id).await {
Ok(Some(m)) => m,
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "not found" })),
));
}
Err(e) => {
tracing::error!("delete_medication lookup failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
if existing.user_id != claims.sub {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "not found" })),
));
}
// Look up by medication_id (UUID), not Mongo _id.
match repo.delete_by_medication_id(&id).await {
Ok(true) => Ok(StatusCode::NO_CONTENT),
Ok(false) => Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "not found" })),
)),
Err(e) => {
tracing::error!("delete_medication failed: {}", e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
))
}
Ok(false) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
@ -259,34 +136,8 @@ pub async fn log_dose(
Extension(claims): Extension<Claims>,
Path(id): Path<String>,
Json(req): Json<LogDoseRequest>,
) -> Result<(StatusCode, Json<MedicationDose>), (StatusCode, Json<serde_json::Value>)> {
) -> Result<(StatusCode, Json<MedicationDose>), StatusCode> {
let database = state.db.get_database();
let repo = MedicationRepository::new(database.collection("medications"));
// Authorization: confirm the caller owns the medication before logging a
// dose against it (otherwise a user could skew another's adherence stats).
let med = match repo.find_by_medication_id(&id).await {
Ok(Some(m)) => m,
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "not found" })),
));
}
Err(e) => {
tracing::error!("log_dose lookup failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
if med.user_id != claims.sub {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "not found" })),
));
}
let now = SystemTime::now();
@ -304,13 +155,7 @@ pub async fn log_dose(
.collection::<crate::models::medication::MedicationDose>("medication_doses")
.insert_one(&dose, None)
.await
.map_err(|e| {
tracing::error!("log_dose insert failed: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
)
})?;
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Populate the generated _id so the caller gets the persisted dose back.
dose.id = result.inserted_id.as_object_id();
@ -320,10 +165,9 @@ pub async fn log_dose(
pub async fn get_adherence(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Extension(_claims): Extension<Claims>,
Path(id): Path<String>,
) -> Result<Json<crate::models::medication::AdherenceStats>, (StatusCode, Json<serde_json::Value>)>
{
) -> Result<Json<crate::models::medication::AdherenceStats>, StatusCode> {
use mongodb::bson::{doc, DateTime};
const PERIOD_DAYS: i64 = 30;
@ -332,28 +176,6 @@ pub async fn get_adherence(
let doses: mongodb::Collection<MedicationDose> = database.collection("medication_doses");
let med_repo = MedicationRepository::new(database.collection("medications"));
// Authorization: owner-only. Look up the medication and confirm ownership
// before computing/returning adherence (which leaks dose history).
let medication = match med_repo.find_by_medication_id(&id).await {
Ok(Some(m)) => {
if m.user_id != claims.sub {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "not found" })),
));
}
Some(m)
}
Ok(None) => None,
Err(e) => {
tracing::error!("get_adherence lookup failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
// Look at doses logged in the last PERIOD_DAYS days for this medication.
let since = DateTime::from_system_time(
SystemTime::now() - std::time::Duration::from_secs(PERIOD_DAYS as u64 * 86400),
@ -366,25 +188,19 @@ pub async fn get_adherence(
let total_logged = doses
.count_documents(filter.clone(), None)
.await
.map_err(|e| {
tracing::error!("get_adherence count failed: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
)
})?;
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let taken_filter = doc! { "$and": [filter, doc! { "taken": true }] };
let taken = doses
.count_documents(taken_filter, None)
.await
.map_err(|e| {
tracing::error!("get_adherence taken count failed: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
)
})?;
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Fetch the medication to check for a dose schedule.
let medication = med_repo
.find_by_medication_id(&id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let (scheduled_doses, total_doses, missed_doses, rate) =
match medication.and_then(|m| m.dose_schedule) {

View file

@ -4,9 +4,10 @@ pub mod health;
pub mod health_stats;
pub mod interactions;
pub mod medications;
pub mod permissions;
pub mod profile;
pub mod profile_share;
pub mod sessions;
pub mod shares;
pub mod users;
// Re-export commonly used handler functions
@ -23,12 +24,10 @@ pub use medications::{
create_medication, delete_medication, get_adherence, get_medication, list_medications,
log_dose, update_medication,
};
pub use permissions::check_permission;
pub use profile::{create_profile, delete_profile, get_profile, list_profiles, update_profile};
pub use profile_share::{
create_share, delete_share, get_profile_shared_aware, get_public_key, list_shared_with_me,
list_shares, rekey_profile,
};
pub use sessions::{get_sessions, revoke_all_sessions, revoke_session};
pub use shares::{create_share, delete_share, list_shares, update_share};
pub use users::{
change_password, delete_account, get_account, get_settings, update_account, update_settings,
};

View file

@ -0,0 +1,62 @@
use axum::{
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
Extension, Json,
};
use serde::{Deserialize, Serialize};
use crate::{auth::jwt::Claims, config::AppState};
#[derive(Debug, Deserialize)]
pub struct CheckPermissionQuery {
pub resource_type: String,
pub resource_id: String,
pub permission: String,
}
#[derive(Debug, Serialize)]
pub struct PermissionCheckResponse {
pub has_permission: bool,
pub resource_type: String,
pub resource_id: String,
pub permission: String,
}
pub async fn check_permission(
State(state): State<AppState>,
Query(params): Query<CheckPermissionQuery>,
Extension(claims): Extension<Claims>,
) -> impl IntoResponse {
let has_permission = match state
.db
.check_user_permission(
&claims.sub,
&params.resource_type,
&params.resource_id,
&params.permission,
)
.await
{
Ok(result) => result,
Err(e) => {
tracing::error!("Failed to check permission: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "failed to check permission"
})),
)
.into_response();
}
};
let response = PermissionCheckResponse {
has_permission,
resource_type: params.resource_type,
resource_id: params.resource_id,
permission: params.permission,
};
(StatusCode::OK, Json(response)).into_response()
}

View file

@ -1,708 +0,0 @@
//! Profile sharing (Phase B).
//!
//! Implements the X25519 envelope: an owner wraps a profile DEK to a
//! recipient's identity public key via ECDH, storing the opaque wrapped blob
//! keyed by (profileId, recipientUserId). Recipients unwrap with their own
//! identity private key. The server never sees the profile DEK.
//!
//! Also provides `authorize_profile_read` — the share-gate used by the data
//! handlers (medications, appointments, health stats) to admit recipients.
use axum::{
extract::{Extension, Path, Query, State},
http::StatusCode,
Json,
};
use mongodb::bson::{oid::ObjectId, DateTime};
use serde::{Deserialize, Serialize};
use crate::{
auth::jwt::Claims,
config::AppState,
models::profile::ProfileRepository,
models::profile_share::{ProfileShare, ProfileShareRepository},
};
fn profile_repo(state: &AppState) -> ProfileRepository {
ProfileRepository::new(state.db.get_database().collection("profiles"))
}
fn share_repo(state: &AppState) -> ProfileShareRepository {
ProfileShareRepository::new(state.db.get_database().collection("profile_shares"))
}
/// The shared profile as seen by the recipient — the profile's metadata
/// (display name blob, kind, relationship) plus the per-share ECDH-wrapped DEK
/// the recipient unwraps with their identity private key. The server returns
/// the wrapped DEK from the share record verbatim and cannot read it.
#[derive(Debug, Serialize)]
pub struct SharedProfileResponse {
pub profile_id: String,
pub owner_account_id: String,
pub name_data: String,
pub name_iv: String,
pub kind: String,
pub relationship: String,
/// Per-share ephemeral X25519 public key (base64 raw). The recipient
/// combines it with their identity private key to derive the wrap key.
pub ephemeral_public_key: String,
/// Profile DEK wrapped under the ECDH-derived key (opaque).
pub wrapped_profile_dek: String,
pub wrapped_profile_dek_iv: String,
pub permissions: Vec<String>,
pub created_at: DateTime,
}
/// A share as listed by the owner (no wrapped DEK on this view — it's only
/// useful to the recipient; the owner already has the profile DEK directly).
#[derive(Debug, Serialize)]
pub struct ShareListingResponse {
pub profile_id: String,
pub recipient_user_id: String,
pub recipient_email: String,
pub permissions: Vec<String>,
pub created_at: DateTime,
pub active: bool,
}
// ---------------------------------------------------------------------------
// Share-gate: resolve the owner userId for a profile the caller may read.
// Used by the data handlers to admit recipients.
// ---------------------------------------------------------------------------
/// If the caller (claims.sub) owns the profile, returns Ok(owner_user_id).
/// Otherwise checks for an active share; if present, returns Ok(owner_user_id)
/// (the profile's owner — recipients read "as the owner" for that profile).
/// Returns Err(404 response) if the caller has no read access.
pub async fn authorize_profile_read(
state: &AppState,
claims: &Claims,
profile_id: &str,
) -> Result<String, (StatusCode, Json<serde_json::Value>)> {
let profiles = profile_repo(state);
// Owner path.
if let Ok(Some(owned)) = profiles
.find_by_profile_id_owned(profile_id, &claims.sub)
.await
{
return Ok(owned.owner_account_id);
}
// Recipient path: an active (unexpired, not-revoked) share for this pair.
let shares = share_repo(state);
match shares.find_active(profile_id, &claims.sub).await {
Ok(Some(share)) => Ok(share.owner_user_id),
Ok(None) => Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "profile not found" })),
)),
Err(e) => {
tracing::error!("Share lookup failed for {}: {}", profile_id, e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
))
}
}
}
// ---------------------------------------------------------------------------
// POST /api/profiles/:id/shares — owner shares a profile to a recipient.
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct CreateShareRequest {
/// The recipient, identified by email.
pub recipient_email: String,
/// Ephemeral X25519 public key (base64 raw) generated by the owner's
/// client for this share.
pub ephemeral_public_key: String,
/// Profile DEK wrapped (client-side, via ECDH) to the recipient. Opaque.
pub wrapped_profile_dek: String,
pub wrapped_profile_dek_iv: String,
#[serde(default = "default_read_permissions")]
pub permissions: Vec<String>,
/// Optional RFC-3339 expiry; null/absent = never expires.
pub expires_at: Option<String>,
}
fn default_read_permissions() -> Vec<String> {
vec!["read".to_string()]
}
pub async fn create_share(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Path(profile_id): Path<String>,
Json(req): Json<CreateShareRequest>,
) -> Result<(StatusCode, Json<ShareListingResponse>), (StatusCode, Json<serde_json::Value>)> {
// Verify the caller owns the profile.
let profiles = profile_repo(&state);
let owner = match profiles
.find_by_profile_id_owned(&profile_id, &claims.sub)
.await
{
Ok(Some(p)) => p.owner_account_id,
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "profile not found" })),
));
}
Err(e) => {
tracing::error!("Profile lookup in create_share failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
// Resolve recipient by email.
let recipient = match state.db.find_user_by_email(&req.recipient_email).await {
Ok(Some(u)) => u,
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "recipient not found" })),
));
}
Err(e) => {
tracing::error!("Recipient lookup failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
let recipient_id = match recipient.id {
Some(id) => id.to_string(),
None => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "invalid recipient state" })),
));
}
};
// Can't share to an account without an identity public key (pre-A1).
if recipient
.identity_public_key
.as_deref()
.unwrap_or("")
.is_empty()
{
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "recipient has no identity public key (account predates the keypair feature)"
})),
));
}
// Don't share to yourself.
if recipient_id == claims.sub {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "cannot share a profile with yourself" })),
));
}
let expires_at = match req.expires_at.as_deref() {
Some(s) => match DateTime::parse_rfc3339_str(s) {
Ok(dt) => Some(dt),
Err(_) => {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "invalid expires_at (expected RFC 3339)" })),
));
}
},
None => None,
};
let now = DateTime::now();
let share = ProfileShare {
id: None,
profile_id: profile_id.clone(),
owner_user_id: owner.clone(),
recipient_user_id: recipient_id.clone(),
ephemeral_public_key: req.ephemeral_public_key,
wrapped_profile_dek: req.wrapped_profile_dek,
wrapped_profile_dek_iv: req.wrapped_profile_dek_iv,
permissions: req.permissions.clone(),
expires_at,
created_at: now,
active: true,
};
let shares = share_repo(&state);
if let Err(e) = shares.upsert(&share).await {
tracing::error!("Share create failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
Ok((
StatusCode::CREATED,
Json(ShareListingResponse {
profile_id,
recipient_user_id: recipient_id,
recipient_email: req.recipient_email,
permissions: req.permissions,
created_at: now,
active: true,
}),
))
}
// ---------------------------------------------------------------------------
// GET /api/profiles/:id/shares — owner lists who they've shared a profile with.
// ---------------------------------------------------------------------------
pub async fn list_shares(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Path(profile_id): Path<String>,
) -> Result<Json<Vec<ShareListingResponse>>, (StatusCode, Json<serde_json::Value>)> {
// Owner-only: confirm ownership.
let profiles = profile_repo(&state);
match profiles
.find_by_profile_id_owned(&profile_id, &claims.sub)
.await
{
Ok(Some(_)) => {}
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "profile not found" })),
));
}
Err(e) => {
tracing::error!("Profile lookup in list_shares failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
}
let shares = share_repo(&state);
let rows = match shares.find_for_profile(&profile_id).await {
Ok(rows) => rows,
Err(e) => {
tracing::error!("Share list failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
// Resolve each recipient's email for display.
let mut out = Vec::with_capacity(rows.len());
for s in rows {
let email = match ObjectId::parse_str(&s.recipient_user_id) {
Ok(oid) => match state.db.find_user_by_id(&oid).await {
Ok(Some(u)) => u.email,
_ => String::new(),
},
Err(_) => String::new(),
};
out.push(ShareListingResponse {
profile_id: s.profile_id,
recipient_user_id: s.recipient_user_id,
recipient_email: email,
permissions: s.permissions,
created_at: s.created_at,
active: s.active,
});
}
Ok(Json(out))
}
// ---------------------------------------------------------------------------
// DELETE /api/profiles/:id/shares/:recipient — soft revoke (hard delete).
// ---------------------------------------------------------------------------
pub async fn delete_share(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Path((profile_id, recipient_user_id)): Path<(String, String)>,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
// Owner-only: confirm ownership.
let profiles = profile_repo(&state);
match profiles
.find_by_profile_id_owned(&profile_id, &claims.sub)
.await
{
Ok(Some(_)) => {}
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "profile not found" })),
));
}
Err(e) => {
tracing::error!("Profile lookup in delete_share failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
}
let shares = share_repo(&state);
match shares.delete(&profile_id, &recipient_user_id).await {
Ok(true) => Ok(StatusCode::NO_CONTENT),
Ok(false) => Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "share not found" })),
)),
Err(e) => {
tracing::error!("Share delete failed: {}", e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
))
}
}
}
// ---------------------------------------------------------------------------
// GET /api/profiles/shared-with-me — recipient lists profiles shared with them.
// ---------------------------------------------------------------------------
pub async fn list_shared_with_me(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
) -> Result<Json<Vec<SharedProfileResponse>>, (StatusCode, Json<serde_json::Value>)> {
let shares = share_repo(&state);
let my_shares = match shares.find_for_recipient(&claims.sub).await {
Ok(rows) => rows,
Err(e) => {
tracing::error!("shared-with-me list failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
// Join each share with its profile's metadata. Only return shares for
// profiles that still exist and are still active/unexpired (find_active
// semantics, re-checked here to avoid listing a just-expired share).
let profiles = profile_repo(&state);
let mut out = Vec::with_capacity(my_shares.len());
for s in my_shares {
// Skip expired/inactive — find_for_recipient returns all rows; filter
// to currently-usable ones for the recipient's view.
if !s.active {
continue;
}
if let Some(exp) = s.expires_at {
if exp <= DateTime::now() {
continue;
}
}
let profile = match profiles.find_by_profile_id(&s.profile_id).await {
Ok(Some(p)) => p,
_ => continue, // profile deleted; skip silently
};
out.push(SharedProfileResponse {
profile_id: profile.profile_id,
owner_account_id: profile.owner_account_id,
name_data: profile.name,
name_iv: profile.name_iv,
kind: profile.kind,
relationship: profile.relationship,
ephemeral_public_key: s.ephemeral_public_key,
wrapped_profile_dek: s.wrapped_profile_dek,
wrapped_profile_dek_iv: s.wrapped_profile_dek_iv,
permissions: s.permissions,
created_at: s.created_at,
});
}
Ok(Json(out))
}
// ---------------------------------------------------------------------------
// GET /api/profiles/:id — extend the existing profile GET to admit recipients.
// This stands in for "the recipient can fetch one shared profile's metadata".
// Implemented here so the share-aware logic lives with the share code.
// ---------------------------------------------------------------------------
/// Fetch a single profile's metadata, admitting both owner and share-recipient.
/// Returns the owner's-view `ProfileResponse` shape (the wrapped profile DEK
/// there is account-wrapped — recipients ignore it and use the share's
/// ECDH-wrapped DEK from /profiles/shared-with-me instead).
pub async fn get_profile_shared_aware(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Path(profile_id): Path<String>,
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
// authorize_profile_read admits owner or active-share recipient.
let _owner = authorize_profile_read(&state, &claims, &profile_id).await?;
let profiles = profile_repo(&state);
match profiles.find_by_profile_id(&profile_id).await {
Ok(Some(p)) => Ok(Json(ProfileResponse::from(p))),
Ok(None) => Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "profile not found" })),
)),
Err(e) => {
tracing::error!("Profile fetch failed: {}", e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
))
}
}
}
// Re-export ProfileResponse from the profile handlers for the shared-aware GET.
pub use crate::handlers::profile::ProfileResponse;
// ---------------------------------------------------------------------------
// GET /api/users/public-key — look up a recipient's identity public key.
// Public (no auth) — public keys are not secret.
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct PublicKeyQuery {
pub email: String,
}
#[derive(Debug, Serialize)]
pub struct PublicKeyResponse {
pub user_id: String,
pub identity_public_key: String,
}
pub async fn get_public_key(
State(state): State<AppState>,
Query(q): Query<PublicKeyQuery>,
) -> Result<Json<PublicKeyResponse>, (StatusCode, Json<serde_json::Value>)> {
let user = match state.db.find_user_by_email(&q.email).await {
Ok(Some(u)) => u,
Ok(None) | Err(_) => {
// Don't leak whether the email exists.
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "no public key for this address" })),
));
}
};
let user_id = user
.id
.ok_or((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "invalid user state" })),
))?
.to_string();
let pk = user.identity_public_key.unwrap_or_default();
if pk.is_empty() {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "no public key for this address" })),
));
}
Ok(Json(PublicKeyResponse {
user_id,
identity_public_key: pk,
}))
}
// ---------------------------------------------------------------------------
// POST /api/profiles/:id/rekey — hard revoke / rotate the profile DEK (Phase C).
//
// The owner's client has already (a) generated a fresh profile DEK, (b)
// re-encrypted all the profile's data under it (client-side), and (c) wrapped
// the new DEK to its own account DEK + to each still-valid recipient's identity
// public key via fresh ECDH envelopes. This call commits the rotation: it
// stores the new owner-wrapped DEK, upserts each submitted recipient envelope,
// and hard-deletes any current recipient NOT in the submitted list (the
// hard-revoke). The server stores only opaque blobs + public keys.
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct RecipientEnvelope {
pub recipient_user_id: String,
pub ephemeral_public_key: String,
pub wrapped_profile_dek: String,
pub wrapped_profile_dek_iv: String,
}
#[derive(Debug, Deserialize)]
pub struct RekeyRequest {
/// The NEW profile DEK, wrapped under the owner's account DEK. Opaque.
pub new_wrapped_profile_dek: String,
pub new_wrapped_profile_dek_iv: String,
/// One fresh ECDH envelope per recipient the owner wants to KEEP. Any
/// current recipient not listed here is hard-revoked.
#[serde(default)]
pub recipient_envelopes: Vec<RecipientEnvelope>,
}
#[derive(Debug, Serialize)]
pub struct RekeyResponse {
pub profile_id: String,
pub wrapped_profile_dek: String,
pub wrapped_profile_dek_iv: String,
/// The recipients still sharing this profile after the rotation.
pub retained_recipient_user_ids: Vec<String>,
}
pub async fn rekey_profile(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Path(profile_id): Path<String>,
Json(req): Json<RekeyRequest>,
) -> Result<(StatusCode, Json<RekeyResponse>), (StatusCode, Json<serde_json::Value>)> {
// 1. Owner-only. (Admin-tier rekey is reserved for a later phase; the
// handler is owner-only for now.)
let profiles = profile_repo(&state);
let owner = match profiles
.find_by_profile_id_owned(&profile_id, &claims.sub)
.await
{
Ok(Some(p)) => p.owner_account_id,
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "profile not found" })),
));
}
Err(e) => {
tracing::error!("rekey profile lookup failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
// 2. Validate each submitted envelope: the owner can only rotate envelopes
// for recipients who ALREADY have an active share. (Adding a new share
// goes through POST /profiles/:id/shares, not rekey.) This prevents
// smuggling in a brand-new recipient via the rekey call.
let shares = share_repo(&state);
let active_shares = match shares.find_active_for_profile(&profile_id).await {
Ok(v) => v,
Err(e) => {
tracing::error!("rekey active-share lookup failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
let active_recipient_ids: std::collections::HashSet<&str> = active_shares
.iter()
.map(|s| s.recipient_user_id.as_str())
.collect();
for env in &req.recipient_envelopes {
if !active_recipient_ids.contains(env.recipient_user_id.as_str()) {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "recipient_envelope references a recipient with no active share; use POST /profiles/:id/shares to add a share",
"recipient_user_id": env.recipient_user_id,
})),
));
}
}
// 3. Rotate the owner share (store the new account-wrapped profile DEK).
// update_wrapped_dek uses find_one_and_update, which returns the PRE-image
// by default — so we don't read the new values back from it; we echo the
// request values (exactly what was stored) in the response below.
match profiles
.update_wrapped_dek(
&profile_id,
&owner,
&req.new_wrapped_profile_dek,
&req.new_wrapped_profile_dek_iv,
)
.await
{
Ok(Some(_)) => {}
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "profile not found" })),
));
}
Err(e) => {
tracing::error!("rekey owner-share rotation failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
}
// 4. Upsert each submitted recipient envelope (fresh ECDH-wrapped DEK +
// ephemeral pubkey, replacing the old envelope). Preserve the original
// share's permissions / expires_at by reading them from the active share.
let now = DateTime::now();
for env in &req.recipient_envelopes {
let original = active_shares
.iter()
.find(|s| s.recipient_user_id == env.recipient_user_id);
let permissions = original
.map(|s| s.permissions.clone())
.unwrap_or_else(|| vec!["read".to_string()]);
let expires_at = original.and_then(|s| s.expires_at);
let new_share = ProfileShare {
id: None,
profile_id: profile_id.clone(),
owner_user_id: owner.clone(),
recipient_user_id: env.recipient_user_id.clone(),
ephemeral_public_key: env.ephemeral_public_key.clone(),
wrapped_profile_dek: env.wrapped_profile_dek.clone(),
wrapped_profile_dek_iv: env.wrapped_profile_dek_iv.clone(),
permissions,
expires_at,
created_at: now,
active: true,
};
if let Err(e) = shares.upsert(&new_share).await {
tracing::error!("rekey recipient upsert failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
}
// 5. Hard-revoke any current recipient NOT in the submitted list. Their
// cached old DEK no longer matches the rotated profile DEK, AND the
// server stops serving them.
let keep: Vec<String> = req
.recipient_envelopes
.iter()
.map(|e| e.recipient_user_id.clone())
.collect();
if let Err(e) = shares
.delete_for_profile_excluding(&profile_id, &keep)
.await
{
tracing::error!("rekey hard-revoke delete failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
Ok((
StatusCode::OK,
Json(RekeyResponse {
profile_id,
// Echo the request values (exactly what was stored). Avoids the
// find_one_and_update pre-image gotcha.
wrapped_profile_dek: req.new_wrapped_profile_dek,
wrapped_profile_dek_iv: req.new_wrapped_profile_dek_iv,
retained_recipient_user_ids: keep,
}),
))
}

View file

@ -0,0 +1,453 @@
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Extension, Json,
};
use mongodb::bson::oid::ObjectId;
use serde::{Deserialize, Serialize};
use validator::Validate;
use crate::{
auth::jwt::Claims,
config::AppState,
models::{permission::Permission, share::Share},
};
#[derive(Debug, Deserialize, Validate)]
pub struct CreateShareRequest {
pub target_user_email: String,
pub resource_type: String,
pub resource_id: Option<String>,
pub permissions: Vec<String>,
#[serde(default)]
pub expires_days: Option<u64>,
}
#[derive(Debug, Serialize)]
pub struct ShareResponse {
pub id: String,
pub target_user_id: String,
pub resource_type: String,
pub resource_id: Option<String>,
pub permissions: Vec<String>,
pub expires_at: Option<i64>,
pub created_at: i64,
pub active: bool,
}
impl TryFrom<Share> for ShareResponse {
type Error = anyhow::Error;
fn try_from(share: Share) -> Result<Self, Self::Error> {
Ok(Self {
id: share.id.map(|id| id.to_string()).unwrap_or_default(),
target_user_id: share.target_user_id.to_string(),
resource_type: share.resource_type,
resource_id: share.resource_id.map(|id| id.to_string()),
permissions: share
.permissions
.into_iter()
.map(|p| p.to_string())
.collect(),
expires_at: share.expires_at.map(|dt| dt.timestamp_millis()),
created_at: share.created_at.timestamp_millis(),
active: share.active,
})
}
}
pub async fn create_share(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Json(req): Json<CreateShareRequest>,
) -> impl IntoResponse {
if let Err(errors) = req.validate() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "validation failed",
"details": errors.to_string()
})),
)
.into_response();
}
// Find target user by email
let target_user = match state.db.find_user_by_email(&req.target_user_email).await {
Ok(Some(user)) => user,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "target user not found"
})),
)
.into_response();
}
Err(e) => {
tracing::error!("Failed to find target user: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "database error"
})),
)
.into_response();
}
};
let target_user_id = match target_user.id {
Some(id) => id,
None => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "target user has no ID"
})),
)
.into_response();
}
};
let owner_id = match ObjectId::parse_str(&claims.sub) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid user ID format"
})),
)
.into_response();
}
};
// Parse resource_id if provided
let resource_id = match req.resource_id {
Some(id) => match ObjectId::parse_str(&id) {
Ok(oid) => Some(oid),
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid resource_id format"
})),
)
.into_response();
}
},
None => None,
};
// Parse permissions - support all permission types
let permissions: Vec<Permission> = req
.permissions
.into_iter()
.filter_map(|p| match p.to_lowercase().as_str() {
"read" => Some(Permission::Read),
"write" => Some(Permission::Write),
"delete" => Some(Permission::Delete),
"share" => Some(Permission::Share),
"admin" => Some(Permission::Admin),
_ => None,
})
.collect();
if permissions.is_empty() {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
"error": "at least one valid permission is required (read, write, delete, share, admin)"
}))).into_response();
}
// Calculate expiration
let expires_at = req.expires_days.map(|days| {
mongodb::bson::DateTime::now().saturating_add_millis(days as i64 * 24 * 60 * 60 * 1000)
});
let share = Share::new(
owner_id,
target_user_id,
req.resource_type,
resource_id,
permissions,
expires_at,
);
match state.db.create_share(&share).await {
Ok(_) => {
let response: ShareResponse = match share.try_into() {
Ok(r) => r,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "failed to create share response"
})),
)
.into_response();
}
};
(StatusCode::CREATED, Json(response)).into_response()
}
Err(e) => {
tracing::error!("Failed to create share: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "failed to create share"
})),
)
.into_response()
}
}
}
pub async fn list_shares(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
) -> impl IntoResponse {
let user_id = &claims.sub;
match state.db.list_shares_for_user(user_id).await {
Ok(shares) => {
let responses: Vec<ShareResponse> = shares
.into_iter()
.filter_map(|s| ShareResponse::try_from(s).ok())
.collect();
(StatusCode::OK, Json(responses)).into_response()
}
Err(e) => {
tracing::error!("Failed to list shares: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "failed to list shares"
})),
)
.into_response()
}
}
}
pub async fn get_share(State(state): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
match state.db.get_share(&id).await {
Ok(Some(share)) => {
let response: ShareResponse = match share.try_into() {
Ok(r) => r,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "failed to create share response"
})),
)
.into_response();
}
};
(StatusCode::OK, Json(response)).into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "share not found"
})),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to get share: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "failed to get share"
})),
)
.into_response()
}
}
}
#[derive(Debug, Deserialize, Validate)]
pub struct UpdateShareRequest {
pub permissions: Option<Vec<String>>,
#[serde(default)]
pub active: Option<bool>,
#[serde(default)]
pub expires_days: Option<u64>,
}
pub async fn update_share(
State(state): State<AppState>,
Path(id): Path<String>,
Extension(claims): Extension<Claims>,
Json(req): Json<UpdateShareRequest>,
) -> impl IntoResponse {
// First get the share
let mut share = match state.db.get_share(&id).await {
Ok(Some(s)) => s,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "share not found"
})),
)
.into_response();
}
Err(e) => {
tracing::error!("Failed to get share: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "failed to get share"
})),
)
.into_response();
}
};
// Verify ownership
let owner_id = match ObjectId::parse_str(&claims.sub) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid user ID format"
})),
)
.into_response();
}
};
if share.owner_id != owner_id {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "not authorized to modify this share"
})),
)
.into_response();
}
// Update fields
if let Some(permissions) = req.permissions {
share.permissions = permissions
.into_iter()
.filter_map(|p| match p.to_lowercase().as_str() {
"read" => Some(Permission::Read),
"write" => Some(Permission::Write),
"delete" => Some(Permission::Delete),
"share" => Some(Permission::Share),
"admin" => Some(Permission::Admin),
_ => None,
})
.collect();
}
if let Some(active) = req.active {
share.active = active;
}
if let Some(days) = req.expires_days {
share.expires_at = Some(
mongodb::bson::DateTime::now().saturating_add_millis(days as i64 * 24 * 60 * 60 * 1000),
);
}
match state.db.update_share(&share).await {
Ok(_) => {
let response: ShareResponse = match share.try_into() {
Ok(r) => r,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "failed to create share response"
})),
)
.into_response();
}
};
(StatusCode::OK, Json(response)).into_response()
}
Err(e) => {
tracing::error!("Failed to update share: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "failed to update share"
})),
)
.into_response()
}
}
}
pub async fn delete_share(
State(state): State<AppState>,
Path(id): Path<String>,
Extension(claims): Extension<Claims>,
) -> impl IntoResponse {
// First get the share to verify ownership
let share = match state.db.get_share(&id).await {
Ok(Some(s)) => s,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "share not found"
})),
)
.into_response()
}
Err(e) => {
tracing::error!("Failed to get share: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "failed to get share"
})),
)
.into_response();
}
};
// Verify ownership
let owner_id = match ObjectId::parse_str(&claims.sub) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid user ID format"
})),
)
.into_response();
}
};
if share.owner_id != owner_id {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "not authorized to delete this share"
})),
)
.into_response();
}
match state.db.delete_share(&id).await {
Ok(_) => (StatusCode::NO_CONTENT, ()).into_response(),
Err(e) => {
tracing::error!("Failed to delete share: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "failed to delete share"
})),
)
.into_response()
}
}
}

View file

@ -0,0 +1,96 @@
use axum::{
extract::{Request, State},
http::StatusCode,
middleware::Next,
response::Response,
};
use crate::config::AppState;
use crate::auth::Claims;
/// Middleware to check if user has permission for a resource
///
/// This middleware checks JWT claims (attached by auth middleware)
/// and verifies the user has the required permission level.
///
/// # Permission Levels
/// - "read": Can view resource
/// - "write": Can modify resource
/// - "admin": Full control including deletion
pub async fn has_permission(
State(state): State<AppState>,
required_permission: String,
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
// Extract user_id from JWT claims (attached by auth middleware)
let user_id = match request.extensions().get::<Claims>() {
Some(claims) => claims.sub.clone(),
None => return Err(StatusCode::UNAUTHORIZED),
};
// Extract resource_id from URL path
let resource_id = match extract_resource_id(request.uri().path()) {
Some(id) => id,
None => return Err(StatusCode::BAD_REQUEST),
};
// Check if user has the required permission (either directly or through shares)
let has_perm = match state.db
.check_permission(&user_id, &resource_id, &required_permission)
.await
{
Ok(allowed) => allowed,
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
if !has_perm {
return Err(StatusCode::FORBIDDEN);
}
Ok(next.run(request).await)
}
/// Extract resource ID from URL path
///
/// # Examples
/// - /api/shares/123 -> Some("123")
/// - /api/users/me/profile -> None
fn extract_resource_id(path: &str) -> Option<String> {
let segments: Vec<&str> = path.split('/').collect();
// Look for ID segment after a resource type
// e.g., /api/shares/:id
for (i, segment) in segments.iter().enumerate() {
if segment == &"shares" || segment == &"permissions" {
if i + 1 < segments.len() {
let id = segments[i + 1];
if !id.is_empty() {
return Some(id.to_string());
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_resource_id() {
assert_eq!(
extract_resource_id("/api/shares/123"),
Some("123".to_string())
);
assert_eq!(
extract_resource_id("/api/shares/abc-123"),
Some("abc-123".to_string())
);
assert_eq!(
extract_resource_id("/api/users/me"),
None
);
}
}

View file

@ -6,8 +6,9 @@ pub mod health_stats;
pub mod interactions;
pub mod lab_result;
pub mod medication;
pub mod permission;
pub mod profile;
pub mod profile_share;
pub mod refresh_token;
pub mod session;
pub mod share;
pub mod user;

View file

@ -0,0 +1,44 @@
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Permission {
Read,
Write,
Delete,
Share,
Admin,
}
impl fmt::Display for Permission {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Read => write!(f, "read"),
Self::Write => write!(f, "write"),
Self::Delete => write!(f, "delete"),
Self::Share => write!(f, "share"),
Self::Admin => write!(f, "admin"),
}
}
}
impl Permission {
pub fn can_read(&self) -> bool {
matches!(self, Self::Read | Self::Admin)
}
pub fn can_write(&self) -> bool {
matches!(self, Self::Write | Self::Admin)
}
pub fn can_delete(&self) -> bool {
matches!(self, Self::Delete | Self::Admin)
}
pub fn can_share(&self) -> bool {
matches!(self, Self::Share | Self::Admin)
}
}
pub type Permissions = Vec<Permission>;

View file

@ -152,31 +152,6 @@ impl ProfileRepository {
.await
}
/// Rotate the profile's wrapped DEK (owner share) — Phase C hard revoke.
/// Stores the new account-wrapped profile DEK verbatim, keyed by
/// (profile_id, owner). Returns the updated profile or None if it doesn't
/// exist / isn't owned by `owner`. The server stores the opaque blob
/// verbatim and cannot decrypt it.
pub async fn update_wrapped_dek(
&self,
profile_id: &str,
owner: &str,
new_wrapped_profile_dek: &str,
new_wrapped_profile_dek_iv: &str,
) -> mongodb::error::Result<Option<Profile>> {
self.collection
.find_one_and_update(
doc! { "profileId": profile_id, "ownerAccountId": owner },
doc! { "$set": {
"wrappedProfileDek": new_wrapped_profile_dek,
"wrappedProfileDekIv": new_wrapped_profile_dek_iv,
"updatedAt": DateTime::now()
}},
None,
)
.await
}
/// Delete a profile keyed by (profile_id, owner). Returns true if a doc
/// was deleted, false if it didn't exist or wasn't owned by `owner`.
pub async fn delete_profile(

View file

@ -1,222 +0,0 @@
use futures::stream::StreamExt;
use mongodb::{
bson::{doc, oid::ObjectId, DateTime},
Collection,
};
use serde::{Deserialize, Serialize};
/// A profile shared from one account (owner) to another (recipient).
///
/// Zero-knowledge envelope (Phase B, see `docs/adr/multi-person-sharing.md`):
/// the owner wraps the profile DEK to the recipient's X25519 identity public
/// key via ECDH, using a fresh ephemeral keypair per share. The server stores
/// only opaque ciphertext + public keys and cannot read the profile DEK.
///
/// `permissions` reserves `read` (Phase B), `write`, and `admin` for later
/// phases. Phase B only grants read access.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileShare {
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
pub id: Option<ObjectId>,
#[serde(rename = "profileId")]
pub profile_id: String,
/// Owner's account id (the profile's owner). Denormalized from the
/// profile for query efficiency without a join.
#[serde(rename = "ownerUserId")]
pub owner_user_id: String,
/// Recipient's account id.
#[serde(rename = "recipientUserId")]
pub recipient_user_id: String,
/// Ephemeral X25519 public key (base64 raw) generated for this share. The
/// recipient combines it with their identity private key to ECDH-derive
/// the wrapping key. Plaintext — public keys are not secret.
#[serde(rename = "ephemeralPublicKey")]
pub ephemeral_public_key: String,
/// Profile DEK wrapped (AES-256-GCM) under the ECDH-derived key. Opaque.
#[serde(rename = "wrappedProfileDek")]
pub wrapped_profile_dek: String,
#[serde(rename = "wrappedProfileDekIv")]
pub wrapped_profile_dek_iv: String,
/// Reserved for later phases. Phase B writes `["read"]`.
#[serde(rename = "permissions", default = "default_read")]
pub permissions: Vec<String>,
/// Optional expiry; if set and past, `find_active` treats the share as gone.
#[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime>,
#[serde(rename = "createdAt")]
pub created_at: DateTime,
/// Supports a future "disable without delete" path. Phase B soft-revoke
/// hard-deletes the doc, but the field is kept for forward-compat.
#[serde(rename = "active", default = "default_true")]
pub active: bool,
}
fn default_read() -> Vec<String> {
vec!["read".to_string()]
}
fn default_true() -> bool {
true
}
pub struct ProfileShareRepository {
collection: Collection<ProfileShare>,
}
impl ProfileShareRepository {
pub fn new(collection: Collection<ProfileShare>) -> Self {
Self { collection }
}
pub async fn create(&self, share: &ProfileShare) -> mongodb::error::Result<()> {
self.collection.insert_one(share, None).await?;
Ok(())
}
/// All shares where the given account is the recipient (for the
/// `/profiles/shared-with-me` endpoint).
pub async fn find_for_recipient(
&self,
recipient_user_id: &str,
) -> mongodb::error::Result<Vec<ProfileShare>> {
let mut cursor = self
.collection
.find(doc! { "recipientUserId": recipient_user_id }, None)
.await?;
let mut out = Vec::new();
while let Some(s) = cursor.next().await {
out.push(s?);
}
Ok(out)
}
/// All shares for a given profile (owner listing who they've shared with).
pub async fn find_for_profile(
&self,
profile_id: &str,
) -> mongodb::error::Result<Vec<ProfileShare>> {
let mut cursor = self
.collection
.find(doc! { "profileId": profile_id }, None)
.await?;
let mut out = Vec::new();
while let Some(s) = cursor.next().await {
out.push(s?);
}
Ok(out)
}
/// A specific (profile, recipient) share regardless of active/expiry state.
pub async fn find(
&self,
profile_id: &str,
recipient_user_id: &str,
) -> mongodb::error::Result<Option<ProfileShare>> {
self.collection
.find_one(
doc! { "profileId": profile_id, "recipientUserId": recipient_user_id },
None,
)
.await
}
/// A specific (profile, recipient) share, only if currently usable:
/// `active == true` and not past `expires_at`. Used by the share-gate.
pub async fn find_active(
&self,
profile_id: &str,
recipient_user_id: &str,
) -> mongodb::error::Result<Option<ProfileShare>> {
let now = DateTime::now();
// active==true AND (expiresAt missing OR expiresAt > now)
let filter = doc! {
"profileId": profile_id,
"recipientUserId": recipient_user_id,
"active": true,
"$or": [
{ "expiresAt": { "$exists": false } },
{ "expiresAt": null },
{ "expiresAt": { "$gt": now } },
],
};
self.collection.find_one(filter, None).await
}
/// All currently-active shares for a profile (Phase C rekey needs the full
/// recipient list to validate submitted envelopes and hard-delete omitted
/// recipients). "Active" = `active==true` and not past `expires_at`.
pub async fn find_active_for_profile(
&self,
profile_id: &str,
) -> mongodb::error::Result<Vec<ProfileShare>> {
let now = DateTime::now();
let filter = doc! {
"profileId": profile_id,
"active": true,
"$or": [
{ "expiresAt": { "$exists": false } },
{ "expiresAt": null },
{ "expiresAt": { "$gt": now } },
],
};
let mut cursor = self.collection.find(filter, None).await?;
let mut out = Vec::new();
while let Some(s) = cursor.next().await {
out.push(s?);
}
Ok(out)
}
/// Hard-delete every share for the profile whose recipient is NOT in
/// `keep_recipient_ids` — the hard-revoke action in Phase C rekey. Recipients
/// omitted from the rekey call lose access at the key level (their cached
/// old DEK won't match the rotated one) AND the server stops serving them.
/// Returns the number deleted.
pub async fn delete_for_profile_excluding(
&self,
profile_id: &str,
keep_recipient_ids: &[String],
) -> mongodb::error::Result<u64> {
let mut filter = doc! { "profileId": profile_id };
if !keep_recipient_ids.is_empty() {
filter.insert("recipientUserId", doc! { "$nin": keep_recipient_ids });
}
let res = self.collection.delete_many(filter, None).await?;
Ok(res.deleted_count)
}
/// Hard-delete (soft-revoke) the (profile, recipient) share. Returns true
/// if a doc was deleted.
pub async fn delete(
&self,
profile_id: &str,
recipient_user_id: &str,
) -> mongodb::error::Result<bool> {
let res = self
.collection
.delete_one(
doc! { "profileId": profile_id, "recipientUserId": recipient_user_id },
None,
)
.await?;
Ok(res.deleted_count > 0)
}
/// Upsert: replace any existing (profile, recipient) share with the given
/// one. Used when re-sharing (e.g. rotating the ephemeral key). Deletes
/// existing rows for the pair first, then inserts.
pub async fn upsert(&self, share: &ProfileShare) -> mongodb::error::Result<()> {
let _ = self
.collection
.delete_one(
doc! {
"profileId": &share.profile_id,
"recipientUserId": &share.recipient_user_id,
},
None,
)
.await?;
self.collection.insert_one(share, None).await?;
Ok(())
}
}

123
backend/src/models/share.rs Normal file
View file

@ -0,0 +1,123 @@
use mongodb::bson::DateTime;
use mongodb::bson::{doc, oid::ObjectId};
use mongodb::Collection;
use serde::{Deserialize, Serialize};
use super::permission::Permission;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Share {
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
pub id: Option<ObjectId>,
pub owner_id: ObjectId,
pub target_user_id: ObjectId,
pub resource_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_id: Option<ObjectId>,
pub permissions: Vec<Permission>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime>,
pub created_at: DateTime,
pub active: bool,
}
impl Share {
pub fn new(
owner_id: ObjectId,
target_user_id: ObjectId,
resource_type: String,
resource_id: Option<ObjectId>,
permissions: Vec<Permission>,
expires_at: Option<DateTime>,
) -> Self {
Self {
id: None,
owner_id,
target_user_id,
resource_type,
resource_id,
permissions,
expires_at,
created_at: DateTime::now(),
active: true,
}
}
pub fn is_expired(&self) -> bool {
if let Some(expires) = self.expires_at {
DateTime::now() > expires
} else {
false
}
}
pub fn has_permission(&self, permission: &Permission) -> bool {
self.permissions.contains(permission) || self.permissions.contains(&Permission::Admin)
}
pub fn revoke(&mut self) {
self.active = false;
}
}
#[derive(Clone)]
pub struct ShareRepository {
collection: Collection<Share>,
}
impl ShareRepository {
pub fn new(collection: Collection<Share>) -> Self {
Self { collection }
}
pub async fn create(&self, share: &Share) -> mongodb::error::Result<Option<ObjectId>> {
let result = self.collection.insert_one(share, None).await?;
Ok(Some(result.inserted_id.as_object_id().unwrap()))
}
pub async fn find_by_id(&self, id: &ObjectId) -> mongodb::error::Result<Option<Share>> {
self.collection.find_one(doc! { "_id": id }, None).await
}
pub async fn find_by_owner(&self, owner_id: &ObjectId) -> mongodb::error::Result<Vec<Share>> {
use futures::stream::TryStreamExt;
self.collection
.find(doc! { "owner_id": owner_id }, None)
.await?
.try_collect()
.await
}
pub async fn find_by_target(
&self,
target_user_id: &ObjectId,
) -> mongodb::error::Result<Vec<Share>> {
use futures::stream::TryStreamExt;
self.collection
.find(
doc! { "target_user_id": target_user_id, "active": true },
None,
)
.await?
.try_collect()
.await
}
pub async fn update(&self, share: &Share) -> mongodb::error::Result<()> {
if let Some(id) = &share.id {
self.collection
.replace_one(doc! { "_id": id }, share, None)
.await?;
}
Ok(())
}
pub async fn delete(&self, share_id: &ObjectId) -> mongodb::error::Result<()> {
self.collection
.delete_one(doc! { "_id": share_id }, None)
.await?;
Ok(())
}
}

View file

@ -1,258 +0,0 @@
//! Write/delete path ownership tests (#12).
//!
//! Verifies the ownership checks added to fix the IDOR: user A creates a
//! medication/appointment; user B cannot update, delete, log-dose-against, or
//! read adherence for it. All cross-user attempts must 404 (we use 404, not
//! 403, to avoid leaking the existence of other users' records).
//!
//! Requires a live MongoDB; skips gracefully otherwise.
mod common;
use serde_json::{json, Value};
macro_rules! require_app {
($app:expr) => {
match $app {
Some(x) => x,
None => {
eprintln!("[integration] skipped (MongoDB unavailable)");
return;
}
}
};
}
fn unique_email() -> String {
format!("test_{}@example.com", uuid::Uuid::new_v4())
}
/// Register a user and return the access token.
async fn register(app: &axum::Router, email: &str) -> String {
let (status, body) = common::send_json(
app,
"POST",
"/api/auth/register",
Some(json!({ "email": email, "username": "tester", "password": "supersecret" })),
None,
)
.await;
assert_eq!(status, 201, "register failed, body: {body}");
body["token"].as_str().unwrap().to_string()
}
/// Create a medication as `token` and return its medication_id.
async fn create_medication(app: &axum::Router, token: &str) -> String {
let (status, body) = common::send_json(
app,
"POST",
"/api/medications",
Some(json!({
"profile_id": "default",
"encrypted_data": { "data": "b3duZXItYmxvYg==", "iv": "aXZ2aXZ2aXZ2aXZ2" },
})),
Some(token),
)
.await;
assert!(
status == 200 || status == 201,
"create_medication failed: {status} {body}"
);
body["medication_id"].as_str().unwrap().to_string()
}
/// Create an appointment as `token` and return its appointment_id.
async fn create_appointment(app: &axum::Router, token: &str) -> String {
let (status, body) = common::send_json(
app,
"POST",
"/api/appointments",
Some(json!({
"profile_id": "default",
"encrypted_data": { "data": "YXBwdC1ibG9i", "iv": "aXZ2aXZ2aXZ2aXZ2" },
"status": "upcoming",
})),
Some(token),
)
.await;
assert!(
status == 200 || status == 201,
"create_appointment failed: {status} {body}"
);
body["appointment_id"].as_str().unwrap().to_string()
}
#[tokio::test]
async fn other_user_cannot_update_or_delete_medication() {
let (app, db_name) = require_app!(common::app_for_test().await);
let token_a = register(&app, &unique_email()).await;
let token_b = register(&app, &unique_email()).await;
let med_id = create_medication(&app, &token_a).await;
// B cannot UPDATE A's medication.
let (status, _body) = common::send_json(
&app,
"POST",
&format!("/api/medications/{med_id}"),
Some(json!({
"encrypted_data": { "data": "aGFja2Vk", "iv": "aXZ2aXZ2aXZ2aXZ2" },
})),
Some(&token_b),
)
.await;
assert_eq!(status, 404, "B must not update A's medication");
// B cannot DELETE A's medication.
let (status, _) = common::send_json(
&app,
"POST",
&format!("/api/medications/{med_id}/delete"),
None,
Some(&token_b),
)
.await;
assert_eq!(status, 404, "B must not delete A's medication");
// A still sees it intact.
let (status, _body) = common::send_json(
&app,
"GET",
&format!("/api/medications/{med_id}"),
None,
Some(&token_a),
)
.await;
assert_eq!(status, 200, "A's medication must be untouched");
common::drop_test_db(&db_name).await;
}
#[tokio::test]
async fn other_user_cannot_log_dose_or_read_adherence_for_others_medication() {
let (app, db_name) = require_app!(common::app_for_test().await);
let token_a = register(&app, &unique_email()).await;
let token_b = register(&app, &unique_email()).await;
let med_id = create_medication(&app, &token_a).await;
// B cannot LOG A DOSE against A's medication (would skew A's adherence).
let (status, _body) = common::send_json(
&app,
"POST",
&format!("/api/medications/{med_id}/log"),
Some(json!({ "taken": true })),
Some(&token_b),
)
.await;
assert_eq!(status, 404, "B must not log a dose against A's medication");
// B cannot READ A's adherence (leaks dose history).
let (status, _body) = common::send_json(
&app,
"GET",
&format!("/api/medications/{med_id}/adherence"),
None,
Some(&token_b),
)
.await;
assert_eq!(status, 404, "B must not read A's adherence");
// A can both (sanity).
let (status, _body) = common::send_json(
&app,
"POST",
&format!("/api/medications/{med_id}/log"),
Some(json!({ "taken": true })),
Some(&token_a),
)
.await;
assert_eq!(status, 201, "A should be able to log a dose: {_body}");
let (status, _body) = common::send_json(
&app,
"GET",
&format!("/api/medications/{med_id}/adherence"),
None,
Some(&token_a),
)
.await;
assert_eq!(status, 200, "A should be able to read adherence");
common::drop_test_db(&db_name).await;
}
#[tokio::test]
async fn other_user_cannot_update_or_delete_appointment() {
let (app, db_name) = require_app!(common::app_for_test().await);
let token_a = register(&app, &unique_email()).await;
let token_b = register(&app, &unique_email()).await;
let appt_id = create_appointment(&app, &token_a).await;
// B cannot UPDATE A's appointment.
let (status, _body) = common::send_json(
&app,
"POST",
&format!("/api/appointments/{appt_id}"),
Some(json!({
"encrypted_data": { "data": "aGFja2Vk", "iv": "aXZ2aXZ2aXZ2aXZ2" },
"status": "cancelled",
})),
Some(&token_b),
)
.await;
assert_eq!(status, 404, "B must not update A's appointment");
// B cannot DELETE A's appointment.
let (status, _) = common::send_json(
&app,
"POST",
&format!("/api/appointments/{appt_id}/delete"),
None,
Some(&token_b),
)
.await;
assert_eq!(status, 404, "B must not delete A's appointment");
// A still sees it.
let (status, _body): (u16, Value) = common::send_json(
&app,
"GET",
&format!("/api/appointments/{appt_id}"),
None,
Some(&token_a),
)
.await;
assert_eq!(status, 200, "A's appointment must be untouched");
common::drop_test_db(&db_name).await;
}
#[tokio::test]
async fn owner_can_update_and_delete_own_medication() {
// Sanity: the new checks must not block the legitimate owner.
let (app, db_name) = require_app!(common::app_for_test().await);
let token = register(&app, &unique_email()).await;
let med_id = create_medication(&app, &token).await;
let (status, _body) = common::send_json(
&app,
"POST",
&format!("/api/medications/{med_id}"),
Some(json!({
"encrypted_data": { "data": "dXBkYXRlZA==", "iv": "aXZ2aXZ2aXZ2aXZ2" },
})),
Some(&token),
)
.await;
assert_eq!(status, 200, "owner should update: {_body}");
let (status, _) = common::send_json(
&app,
"POST",
&format!("/api/medications/{med_id}/delete"),
None,
Some(&token),
)
.await;
assert_eq!(status, 204, "owner should delete");
common::drop_test_db(&db_name).await;
}

View file

@ -1,382 +0,0 @@
//! Hard-revoke / re-key integration tests (Phase C).
//!
//! Verifies POST /api/profiles/:id/rekey: rotating the profile's wrapped DEK,
//! upserting submitted recipient envelopes (fresh ECDH wraps), and hard-
//! deleting any current recipient NOT in the submitted list. Wire-level only
//! (opaque blobs — the server never inspects them, so the tests don't need
//! real crypto).
//!
//! Requires a live MongoDB; skips gracefully otherwise.
mod common;
use serde_json::{json, Value};
macro_rules! require_app {
($app:expr) => {
match $app {
Some(x) => x,
None => {
eprintln!("[integration] skipped (MongoDB unavailable)");
return;
}
}
};
}
fn unique_email() -> String {
format!("test_{}@example.com", uuid::Uuid::new_v4())
}
/// Register a fully-set-up user (identity key + default self profile).
async fn register_full(app: &axum::Router, email: &str) -> Value {
let (status, body) = common::send_json(
app,
"POST",
"/api/auth/register",
Some(json!({
"email": email,
"username": email,
"password": "supersecret",
"identity_public_key": format!("pub-{email}"),
"default_profile_name_data": "n",
"default_profile_name_iv": "i",
"default_wrapped_profile_dek": "owner-dek-v1",
"default_wrapped_profile_dek_iv": "owner-dek-iv-v1",
})),
None,
)
.await;
assert_eq!(status, 201, "register_full failed, body: {body}");
body
}
fn token(body: &Value) -> String {
body["token"].as_str().unwrap().to_string()
}
fn self_profile_id(body: &Value) -> String {
format!("profile_{}", body["user_id"].as_str().unwrap())
}
/// Owner shares profile to recipient_email; returns the recipient's user_id
/// (looked up from the share listing afterward).
async fn share_to(
app: &axum::Router,
owner_token: &str,
profile_id: &str,
recipient_email: &str,
) -> String {
let (status, _) = common::send_json(
app,
"POST",
&format!("/api/profiles/{profile_id}/shares"),
Some(json!({
"recipient_email": recipient_email,
"ephemeral_public_key": format!("eph-{recipient_email}"),
"wrapped_profile_dek": format!("wrap-{recipient_email}"),
"wrapped_profile_dek_iv": "iv",
"permissions": ["read"],
})),
Some(owner_token),
)
.await;
assert_eq!(status, 201, "share_to failed");
// Look up the recipient's user_id from the listing.
let (_, listing) = common::send_json(
app,
"GET",
&format!("/api/profiles/{profile_id}/shares"),
None,
Some(owner_token),
)
.await;
listing
.as_array()
.unwrap()
.iter()
.find(|s| s["recipient_email"] == recipient_email)
.unwrap()["recipient_user_id"]
.as_str()
.unwrap()
.to_string()
}
#[tokio::test]
async fn rekey_rotates_owner_share_and_keeps_listed_recipients() {
let (app, db_name) = require_app!(common::app_for_test().await);
let owner_body = register_full(&app, &unique_email()).await;
let owner_token = token(&owner_body);
let profile_id = self_profile_id(&owner_body);
let recipient_b = unique_email();
let recipient_c = unique_email();
register_full(&app, &recipient_b).await;
register_full(&app, &recipient_c).await;
let b_uid = share_to(&app, &owner_token, &profile_id, &recipient_b).await;
let _c_uid = share_to(&app, &owner_token, &profile_id, &recipient_c).await;
// Owner rekeys: keeps B (fresh envelope), omits C (hard-revoke).
let (status, body) = common::send_json(
&app,
"POST",
&format!("/api/profiles/{profile_id}/rekey"),
Some(json!({
"new_wrapped_profile_dek": "owner-dek-v2",
"new_wrapped_profile_dek_iv": "owner-dek-iv-v2",
"recipient_envelopes": [{
"recipient_user_id": b_uid,
"ephemeral_public_key": "eph-b-v2",
"wrapped_profile_dek": "wrap-b-v2",
"wrapped_profile_dek_iv": "iv-v2",
}],
})),
Some(&owner_token),
)
.await;
assert_eq!(status, 200, "rekey should return 200, body: {body}");
// Owner share rotated.
assert_eq!(body["wrapped_profile_dek"], "owner-dek-v2");
assert_eq!(body["wrapped_profile_dek_iv"], "owner-dek-iv-v2");
// Retained = [B].
let retained: Vec<&str> = body["retained_recipient_user_ids"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(retained, vec![b_uid.as_str()]);
// The profile's stored owner-share reflects v2.
let (_, profile_body) = common::send_json(
&app,
"GET",
&format!("/api/profiles/{profile_id}"),
None,
Some(&owner_token),
)
.await;
assert_eq!(profile_body["wrapped_profile_dek"], "owner-dek-v2");
common::drop_test_db(&db_name).await;
}
#[tokio::test]
async fn rekey_hard_revokes_omitted_recipient() {
let (app, db_name) = require_app!(common::app_for_test().await);
let owner_body = register_full(&app, &unique_email()).await;
let owner_token = token(&owner_body);
let profile_id = self_profile_id(&owner_body);
let recipient_b = unique_email();
let recipient_c = unique_email();
let b_body = register_full(&app, &recipient_b).await;
let c_body = register_full(&app, &recipient_c).await;
let b_token = token(&b_body);
let c_token = token(&c_body);
let b_uid = share_to(&app, &owner_token, &profile_id, &recipient_b).await;
let _c_uid = share_to(&app, &owner_token, &profile_id, &recipient_c).await;
// Before rekey: both recipients see the profile in shared-with-me.
let (_, shared_before) = common::send_json(
&app,
"GET",
"/api/profiles/shared-with-me",
None,
Some(&c_token),
)
.await;
assert_eq!(
shared_before.as_array().unwrap().len(),
1,
"C should see the shared profile before rekey"
);
// Rekey keeping only B.
let (status, _) = common::send_json(
&app,
"POST",
&format!("/api/profiles/{profile_id}/rekey"),
Some(json!({
"new_wrapped_profile_dek": "owner-dek-v2",
"new_wrapped_profile_dek_iv": "owner-dek-iv-v2",
"recipient_envelopes": [{
"recipient_user_id": b_uid,
"ephemeral_public_key": "eph-b-v2",
"wrapped_profile_dek": "wrap-b-v2",
"wrapped_profile_dek_iv": "iv-v2",
}],
})),
Some(&owner_token),
)
.await;
assert_eq!(status, 200);
// B (kept) still sees it, with the rotated envelope.
let (_, shared_b) = common::send_json(
&app,
"GET",
"/api/profiles/shared-with-me",
None,
Some(&b_token),
)
.await;
let arr_b = shared_b.as_array().unwrap();
assert_eq!(arr_b.len(), 1, "B sees exactly the one shared profile");
assert_eq!(
arr_b[0]["wrapped_profile_dek"], "wrap-b-v2",
"B's envelope rotated"
);
// C (omitted) no longer sees it.
let (_, shared_c) = common::send_json(
&app,
"GET",
"/api/profiles/shared-with-me",
None,
Some(&c_token),
)
.await;
assert_eq!(
shared_c.as_array().unwrap().len(),
0,
"C must be hard-revoked from shared-with-me"
);
// And the share-gate 404s C on direct read.
let (status, _) = common::send_json(
&app,
"GET",
&format!("/api/profiles/{profile_id}"),
None,
Some(&c_token),
)
.await;
assert_eq!(
status, 404,
"C must be denied by the gate after hard revoke"
);
common::drop_test_db(&db_name).await;
}
#[tokio::test]
async fn rekey_rejects_envelope_for_recipient_with_no_share() {
let (app, db_name) = require_app!(common::app_for_test().await);
let owner_body = register_full(&app, &unique_email()).await;
let owner_token = token(&owner_body);
let profile_id = self_profile_id(&owner_body);
// A recipient who was never shared with.
let stranger_body = register_full(&app, &unique_email()).await;
let stranger_uid = stranger_body["user_id"].as_str().unwrap().to_string();
let (status, body) = common::send_json(
&app,
"POST",
&format!("/api/profiles/{profile_id}/rekey"),
Some(json!({
"new_wrapped_profile_dek": "owner-dek-v2",
"new_wrapped_profile_dek_iv": "owner-dek-iv-v2",
"recipient_envelopes": [{
"recipient_user_id": stranger_uid,
"ephemeral_public_key": "x",
"wrapped_profile_dek": "y",
"wrapped_profile_dek_iv": "z",
}],
})),
Some(&owner_token),
)
.await;
assert_eq!(
status, 400,
"envelope for a recipient with no share must be rejected: {body}"
);
common::drop_test_db(&db_name).await;
}
#[tokio::test]
async fn rekey_rejects_non_owner() {
let (app, db_name) = require_app!(common::app_for_test().await);
let owner_body = register_full(&app, &unique_email()).await;
let profile_id = self_profile_id(&owner_body);
let other_token = token(&register_full(&app, &unique_email()).await);
let (status, _) = common::send_json(
&app,
"POST",
&format!("/api/profiles/{profile_id}/rekey"),
Some(json!({
"new_wrapped_profile_dek": "x",
"new_wrapped_profile_dek_iv": "y",
"recipient_envelopes": [],
})),
Some(&other_token),
)
.await;
assert_eq!(status, 404, "non-owner rekey must be rejected");
// Profile is unchanged.
let (_, profile_body) = common::send_json(
&app,
"GET",
&format!("/api/profiles/{profile_id}"),
None,
Some(token(&owner_body).as_str()),
)
.await;
assert_eq!(profile_body["wrapped_profile_dek"], "owner-dek-v1");
common::drop_test_db(&db_name).await;
}
#[tokio::test]
async fn rekey_with_empty_envelopes_revokes_all_recipients() {
// Keeping nobody = hard-revoke everyone (e.g. suspected compromise).
let (app, db_name) = require_app!(common::app_for_test().await);
let owner_body = register_full(&app, &unique_email()).await;
let owner_token = token(&owner_body);
let profile_id = self_profile_id(&owner_body);
let recipient_b = unique_email();
register_full(&app, &recipient_b).await;
share_to(&app, &owner_token, &profile_id, &recipient_b).await;
let (status, body) = common::send_json(
&app,
"POST",
&format!("/api/profiles/{profile_id}/rekey"),
Some(json!({
"new_wrapped_profile_dek": "owner-dek-v2",
"new_wrapped_profile_dek_iv": "owner-dek-iv-v2",
"recipient_envelopes": [],
})),
Some(&owner_token),
)
.await;
assert_eq!(
status, 200,
"rekey with no envelopes should succeed: {body}"
);
assert_eq!(
body["retained_recipient_user_ids"]
.as_array()
.unwrap()
.len(),
0
);
// The shares listing is now empty.
let (_, listing) = common::send_json(
&app,
"GET",
&format!("/api/profiles/{profile_id}/shares"),
None,
Some(&owner_token),
)
.await;
assert_eq!(listing.as_array().unwrap().len(), 0);
common::drop_test_db(&db_name).await;
}

View file

@ -1,456 +0,0 @@
//! Profile-sharing integration tests (Phase B).
//!
//! Exercises the X25519 envelope endpoints and the share-gate: an owner
//! shares a profile to a recipient; the recipient sees it in
//! /profiles/shared-with-me and can read its data; revoke cuts off access.
//! All wrapped blobs are opaque strings — the server never inspects them, so
//! the test doesn't need real crypto.
//!
//! Requires a live MongoDB; skips gracefully otherwise.
mod common;
use serde_json::{json, Value};
macro_rules! require_app {
($app:expr) => {
match $app {
Some(x) => x,
None => {
eprintln!("[integration] skipped (MongoDB unavailable)");
return;
}
}
};
}
fn unique_email() -> String {
format!("test_{}@example.com", uuid::Uuid::new_v4())
}
/// URL-encode a value for a query string (emails contain '@').
fn q(email: &str) -> String {
email.replace('@', "%40")
}
/// Register a fully-set-up user (identity public key + a default self profile)
/// and return the access token. The owner profile is `profile_<user_id>`.
async fn register_full(app: &axum::Router, email: &str) -> String {
let (status, body) = common::send_json(
app,
"POST",
"/api/auth/register",
Some(json!({
"email": email,
"username": email,
"password": "supersecret",
"identity_public_key": format!("pubkey-{email}"),
"default_profile_name_data": "name-blob",
"default_profile_name_iv": "name-iv",
"default_wrapped_profile_dek": "dek-blob",
"default_wrapped_profile_dek_iv": "dek-iv",
})),
None,
)
.await;
assert_eq!(status, 201, "register_full failed, body: {body}");
body["token"].as_str().unwrap().to_string()
}
/// Register a user with NO identity public key (simulates a pre-A1 account).
async fn register_no_key(app: &axum::Router, email: &str) -> String {
let (status, body) = common::send_json(
app,
"POST",
"/api/auth/register",
Some(json!({ "email": email, "username": email, "password": "supersecret" })),
None,
)
.await;
assert_eq!(status, 201, "register_no_key failed, body: {body}");
body["token"].as_str().unwrap().to_string()
}
/// The owner's self profile id (deterministic contract).
fn self_profile_id(owner_token_response: &Value) -> String {
format!(
"profile_{}",
owner_token_response["user_id"].as_str().unwrap()
)
}
#[tokio::test]
async fn public_key_lookup_returns_recipient_identity_key() {
let (app, db_name) = require_app!(common::app_for_test().await);
let owner_email = unique_email();
let _token = register_full(&app, &owner_email).await;
let (status, body) = common::send_json(
&app,
"GET",
&format!("/api/users/public-key?email={}", q(&owner_email)),
None,
None,
)
.await;
assert_eq!(status, 200, "public-key lookup, body: {body}");
assert_eq!(body["identity_public_key"], format!("pubkey-{owner_email}"));
assert!(!body["user_id"].as_str().unwrap().is_empty());
common::drop_test_db(&db_name).await;
}
#[tokio::test]
async fn public_key_lookup_404s_for_unknown_or_keyless_user() {
let (app, db_name) = require_app!(common::app_for_test().await);
let keyless_email = unique_email();
let _t = register_no_key(&app, &keyless_email).await;
// Unknown address.
let (status, _) = common::send_json(
&app,
"GET",
"/api/users/public-key?email=does-not-exist%40example.com",
None,
None,
)
.await;
assert_eq!(status, 404);
// Existing user but no key.
let (status, _) = common::send_json(
&app,
"GET",
&format!("/api/users/public-key?email={}", q(&keyless_email)),
None,
None,
)
.await;
assert_eq!(status, 404);
common::drop_test_db(&db_name).await;
}
#[tokio::test]
async fn owner_shares_recipient_reads_owner_revokes() {
let (app, db_name) = require_app!(common::app_for_test().await);
let owner_email = unique_email();
let recipient_email = unique_email();
// Register owner + recipient (both with identity keys + self profiles).
let (_, owner_body) = common::send_json(
&app,
"POST",
"/api/auth/register",
Some(json!({
"email": owner_email,
"username": owner_email,
"password": "supersecret",
"identity_public_key": "owner-pub",
"default_profile_name_data": "n", "default_profile_name_iv": "i",
"default_wrapped_profile_dek": "d", "default_wrapped_profile_dek_iv": "v",
})),
None,
)
.await;
let owner_token = owner_body["token"].as_str().unwrap().to_string();
let owner_profile = self_profile_id(&owner_body);
let recipient_token = register_full(&app, &recipient_email).await;
// Owner shares their self profile to the recipient.
let (status, _) = common::send_json(
&app,
"POST",
&format!("/api/profiles/{owner_profile}/shares"),
Some(json!({
"recipient_email": recipient_email,
"ephemeral_public_key": "ephemeral-pub",
"wrapped_profile_dek": "wrapped-dek",
"wrapped_profile_dek_iv": "wrapped-iv",
"permissions": ["read"],
})),
Some(&owner_token),
)
.await;
assert_eq!(status, 201, "share create should return 201");
// Recipient sees it under /profiles/shared-with-me.
let (status, body) = common::send_json(
&app,
"GET",
"/api/profiles/shared-with-me",
None,
Some(&recipient_token),
)
.await;
assert_eq!(status, 200, "shared-with-me, body: {body}");
let arr = body.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["profile_id"], owner_profile);
assert_eq!(arr[0]["ephemeral_public_key"], "ephemeral-pub");
assert_eq!(arr[0]["wrapped_profile_dek"], "wrapped-dek");
// Recipient can GET the shared profile (share-aware GET).
let (status, _) = common::send_json(
&app,
"GET",
&format!("/api/profiles/{owner_profile}"),
None,
Some(&recipient_token),
)
.await;
assert_eq!(status, 200, "recipient should read shared profile");
// Recipient can list the owner's medications for that profile (the owner
// has none, but the share-gate must admit the request rather than 404).
let (status, body) = common::send_json(
&app,
"GET",
&format!("/api/medications?profile_id={owner_profile}"),
None,
Some(&recipient_token),
)
.await;
assert_eq!(status, 200, "recipient meds read, body: {body}");
assert_eq!(body.as_array().unwrap().len(), 0);
// Recipient attempts a write to the shared profile. The medication create
// handler (a) doesn't enforce ownership on writes yet (issue #12) and
// (b) returns 200 OK (not 201) on success. So the write currently succeeds
// but is attributed to the RECIPIENT's user_id, not the owner — meaning it
// does NOT pollute the owner's view of that profile. Accept 200 (current,
// #12 not fixed) or 403 (once #12 lands). Either way the owner's records
// must be untouched.
let (write_status, _body) = common::send_json(
&app,
"POST",
"/api/medications",
Some(json!({
"profile_id": owner_profile,
"encrypted_data": { "data": "x", "iv": "y" },
"active": true,
})),
Some(&recipient_token),
)
.await;
assert!(
write_status == 200 || write_status == 403,
"write outcome: {write_status}"
);
let (status, owner_meds) = common::send_json(
&app,
"GET",
&format!("/api/medications?profile_id={owner_profile}"),
None,
Some(&owner_token),
)
.await;
assert_eq!(status, 200);
assert_eq!(
owner_meds.as_array().unwrap().len(),
0,
"owner's data untouched"
);
// Owner revokes.
// Look up the recipient's user id from the shares listing.
let (_, shares) = common::send_json(
&app,
"GET",
&format!("/api/profiles/{owner_profile}/shares"),
None,
Some(&owner_token),
)
.await;
let recipient_uid = shares[0]["recipient_user_id"].as_str().unwrap().to_string();
let (status, _) = common::send_json(
&app,
"DELETE",
&format!("/api/profiles/{owner_profile}/shares/{recipient_uid}"),
None,
Some(&owner_token),
)
.await;
assert_eq!(status, 204, "revoke should return 204");
// Recipient now loses read access.
let (status, _) = common::send_json(
&app,
"GET",
&format!("/api/profiles/{owner_profile}"),
None,
Some(&recipient_token),
)
.await;
assert_eq!(status, 404, "revoked recipient should get 404");
let (status, body) = common::send_json(
&app,
"GET",
"/api/profiles/shared-with-me",
None,
Some(&recipient_token),
)
.await;
assert_eq!(status, 200);
assert_eq!(
body.as_array().unwrap().len(),
0,
"shared-with-me now empty"
);
common::drop_test_db(&db_name).await;
}
#[tokio::test]
async fn share_rejects_bad_recipient_states() {
let (app, db_name) = require_app!(common::app_for_test().await);
let owner_email = unique_email();
let (_, owner_body) = common::send_json(
&app,
"POST",
"/api/auth/register",
Some(json!({
"email": owner_email, "username": owner_email, "password": "supersecret",
"identity_public_key": "owner-pub",
"default_profile_name_data": "n", "default_profile_name_iv": "i",
"default_wrapped_profile_dek": "d", "default_wrapped_profile_dek_iv": "v",
})),
None,
)
.await;
let owner_token = owner_body["token"].as_str().unwrap().to_string();
let owner_profile = self_profile_id(&owner_body);
// Share to a nonexistent recipient → 404.
let (status, _) = common::send_json(
&app,
"POST",
&format!("/api/profiles/{owner_profile}/shares"),
Some(json!({
"recipient_email": "ghost@example.com",
"ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v",
})),
Some(&owner_token),
)
.await;
assert_eq!(status, 404, "share to ghost should 404");
// Share to a user without an identity key → 404.
let keyless_email = unique_email();
let _t = register_no_key(&app, &keyless_email).await;
let (status, _) = common::send_json(
&app,
"POST",
&format!("/api/profiles/{owner_profile}/shares"),
Some(json!({
"recipient_email": keyless_email,
"ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v",
})),
Some(&owner_token),
)
.await;
assert_eq!(status, 404, "share to keyless user should 404");
// Share to self → 400.
let (status, _) = common::send_json(
&app,
"POST",
&format!("/api/profiles/{owner_profile}/shares"),
Some(json!({
"recipient_email": owner_email,
"ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v",
})),
Some(&owner_token),
)
.await;
assert_eq!(status, 400, "share to self should 400");
// Non-owner cannot create a share for someone else's profile.
let other_token = register_full(&app, &unique_email()).await;
let (status, _) = common::send_json(
&app,
"POST",
&format!("/api/profiles/{owner_profile}/shares"),
Some(json!({
"recipient_email": unique_email(),
"ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v",
})),
Some(&other_token),
)
.await;
assert_eq!(
status, 404,
"non-owner share attempt should 404 (profile not found for them)"
);
common::drop_test_db(&db_name).await;
}
#[tokio::test]
async fn expired_share_is_treated_as_absent() {
let (app, db_name) = require_app!(common::app_for_test().await);
let (_, owner_body) = common::send_json(
&app,
"POST",
"/api/auth/register",
Some(json!({
"email": unique_email(), "username": "owner", "password": "supersecret",
"identity_public_key": "owner-pub",
"default_profile_name_data": "n", "default_profile_name_iv": "i",
"default_wrapped_profile_dek": "d", "default_wrapped_profile_dek_iv": "v",
})),
None,
)
.await;
let owner_token = owner_body["token"].as_str().unwrap().to_string();
let owner_profile = self_profile_id(&owner_body);
let recipient_email = unique_email();
let recipient_token = register_full(&app, &recipient_email).await;
// Share with an expiry in the past.
let (status, _) = common::send_json(
&app,
"POST",
&format!("/api/profiles/{owner_profile}/shares"),
Some(json!({
"recipient_email": recipient_email,
"ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v",
"expires_at": "2020-01-01T00:00:00Z",
})),
Some(&owner_token),
)
.await;
assert_eq!(status, 201);
// Recipient cannot read — expired share is invisible to the gate.
let (status, _) = common::send_json(
&app,
"GET",
&format!("/api/profiles/{owner_profile}"),
None,
Some(&recipient_token),
)
.await;
assert_eq!(status, 404, "expired share should not grant access");
let (status, body) = common::send_json(
&app,
"GET",
"/api/profiles/shared-with-me",
None,
Some(&recipient_token),
)
.await;
assert_eq!(status, 200);
assert_eq!(
body.as_array().unwrap().len(),
0,
"expired share hidden from list"
);
common::drop_test_db(&db_name).await;
}

View file

@ -11,15 +11,14 @@ the documentation reconciliation. They are historical context, not current specs
|--------|-------|
| [tech-stack-decision.md](./tech-stack-decision.md) | Master stack choice: Rust/Axum backend, React frontend, MongoDB, JWT |
| [mongodb-schema-decision.md](./mongodb-schema-decision.md) | Document model + at-rest encryption approach |
| [jwt-authentication-decision.md](./jwt-authentication-decision.md) | JWT access/refresh tokens, `token_version` revocation, refresh rotation — *reconciled with code 2026-07-18* |
| [jwt-authentication-decision.md](./jwt-authentication-decision.md) | JWT access/refresh tokens, recovery phrases |
| [frontend-decision-summary.md](./frontend-decision-summary.md) | React (web) + React Native (mobile, future) split |
| [state-management-decision.md](./state-management-decision.md) | Client state — *superseded*: decision was Redux Toolkit, **actual code uses Zustand** |
| [monorepo-structure.md](./monorepo-structure.md) | Repository layout (`backend/`, `web/`, `mobile/`, `docs/`) |
| [backend-deployment-constraints.md](./backend-deployment-constraints.md) | Deployment requirements (Solaria, Docker) |
| [mobile-health-frameworks-data.md](./mobile-health-frameworks-data.md) | HealthKit / Health Connect data-type reference (for future mobile work) |
| [android-health-connect-data-types.md](./android-health-connect-data-types.md) | Android Health Connect data types |
| [zero-knowledge-encryption.md](./zero-knowledge-encryption.md) | Client-side zero-knowledge encryption (AES-256-GCM, double-PBKDF2, wrapped-DEK recovery; Phase 1 + Phase 2 implemented) |
| [multi-person-sharing.md](./multi-person-sharing.md) | Per-profile DEK + X25519 envelope for family/caregiver sharing — *Decided, not yet implemented* (issue #3) |
| [zero-knowledge-encryption.md](./zero-knowledge-encryption.md) | Client-side zero-knowledge encryption (AES-256-GCM, double-PBKDF2, Phase 1) |
> **Note**: Where a decision diverges from the implemented code (e.g. state
> management), the code is the source of truth and the ADR is kept only as

View file

@ -1,185 +1,174 @@
# ADR: JWT Authentication
# JWT Authentication Decision Summary
**Status**: Implemented (current code is the source of truth)
**Date**: 2026-02-14 (original decision), 2026-07-18 (reconciled with code)
**Date**: 2026-02-14
**Decision**: **JWT with Refresh Tokens + Recovery Phrases**
> This ADR was originally written during Phase 1 research and described several
> things that were never built (bcrypt, Redis, family-role permission claims in
> the JWT, the specific recovery-phrase flow). It has been rewritten to match
> the implementation. The original reasoning is preserved in the "History"
> section at the end. Where code and the old text disagree, **the code wins**.
---
## Context
## Authentication Strategy
Normogen authenticates accounts with email + password and authorizes API
requests with stateless JWTs. This sits *underneath* the zero-knowledge
encryption layer (see [`zero-knowledge-decryption.md`](./zero-knowledge-encryption.md)
and [`encryption.md`](../product/encryption.md)): authentication proves who you
are and lets you fetch your wrapped DEK; the client-side ZK layer then unlocks
your data. The auth system never sees plaintext health data.
### Primary: JWT (JSON Web Tokens)
## Decision
**Why JWT?**
- Stateless design scales to 1000+ concurrent connections
- Works perfectly with mobile apps (AsyncStorage)
- No server-side session storage needed
- Easy to scale Axum horizontally
### Token model
### Token Types
Two JWTs, both HS256-signed with a shared secret from config:
**Access Token** (15 minutes)
- Used for API requests
- Short-lived for security
- Contains: user_id, email, family_id, permissions
| Token | Lifetime | Claims | Purpose |
|---|---|---|---|
| Access | configurable (`JwtConfig.access_token_expiry_minutes`, default **15 min**) | `{sub, exp, iat, user_id, email, token_version}` | Sent on every API request |
| Refresh | configurable (`JwtConfig.refresh_token_expiry_days`, default **30 days**) | `{sub, exp, iat, jti, user_id, token_version}` | Exchange for a new access token |
**Refresh Token** (30 days)
- Used to get new access tokens
- Long-lived for convenience
- Stored in MongoDB for revocation
- Rotated on every refresh
> Reference: `backend/src/auth/jwt.rs` (`Claims`, `RefreshClaims`, `JwtService`).
---
The lifetimes are **config-driven**, not hardcoded — the original ADR's "15 min
/ 30 days" are the defaults, not constants.
## Token Revocation Strategies
### Claims (actual, as implemented)
### 1. Refresh Token Blacklist (Recommended) ⭐
- Store refresh tokens in MongoDB
- Mark as revoked on logout
- Check on every refresh
The earlier ADR listed richer claims (`family_id`, `permissions`, `token_type`).
**These are not in the shipped access token.** The real `Claims` struct is:
### 2. Token Versioning
- Include version in JWT claims
- Increment on password change
- Invalidate all tokens when version changes
```rust
pub struct Claims { // access token
pub sub: String, // = user_id (ObjectId hex)
pub exp: usize,
pub iat: usize,
pub user_id: String,
pub email: String,
pub token_version: i32,
}
### 3. Access Token Blacklist (Optional)
- Store revoked access tokens in Redis
- For immediate revocation
- Auto-expires with TTL
pub struct RefreshClaims { // refresh token
pub sub: String,
pub exp: usize,
pub iat: usize,
pub jti: String, // unique per token (see below)
pub user_id: String,
pub token_version: i32,
---
## Refresh Token Pattern
### Token Rotation (Security Best Practice) ⭐
**Flow**:
1. Client sends refresh_token
2. Server verifies refresh_token (not revoked, not expired)
3. Server generates new access_token
4. Server generates new refresh_token
5. Server revokes old refresh_token
6. Server returns new tokens
**Why?** Prevents reuse of stolen refresh tokens
---
## Zero-Knowledge Password Recovery
### Recovery Phrases (from encryption.md)
**Registration**:
1. Client generates recovery phrase (random 32 bytes)
2. Client encrypts recovery phrase with password
3. Client sends: email, password hash, encrypted recovery phrase
4. Server stores: email, password hash, encrypted recovery phrase
**Password Recovery**:
1. User requests recovery (enters email)
2. Server returns: encrypted recovery phrase
3. Client decrypts with recovery key (user enters manually)
4. User enters new password
5. Client re-encrypts recovery phrase with new password
6. Client sends: new password hash, re-encrypted recovery phrase
7. Server updates: password hash, encrypted recovery phrase, token_version + 1
8. All existing tokens invalidated (version mismatch)
---
## Family Member Access Control
### Permissions in JWT
```typescript
// JWT permissions based on family role
{
"parent": [
"read:own_data",
"write:own_data",
"read:family_data",
"write:family_data",
"manage:family_members",
"delete:data"
],
"child": [
"read:own_data",
"write:own_data"
],
"elderly": [
"read:own_data",
"write:own_data",
"read:family_data"
]
}
```
Note what is **absent** and why:
### Permission Middleware
- **No `family_id` or `permissions` in the token.** Family/permission
enforcement is not implemented at the auth layer (see issue #3 — multi-person
sharing is an open design problem). Putting unenforced claims in the token
would imply protection that doesn't exist.
- (Historical: an orphaned `backend/src/auth/claims.rs` once defined a
duplicate `AccessClaims` struct with `family_id`/`permissions`/`jti`. It
was dead code — never declared as a module, never imported — and has been
removed; see issue #5.)
- **No `token_type` discriminator.** Access and refresh claims are different
structs, so a token can only decode as one or the other — the field is
redundant and was dropped.
- Check permissions on protected routes
- Return 403 Forbidden if insufficient permissions
- Works with JWT claims
### Refresh tokens: persistence, rotation, reuse detection
---
- Refresh tokens are **stored in MongoDB** (`refresh_tokens` collection) — not
Redis (the original ADR listed Redis as an option; it is not used).
- The `jti` claim makes every refresh token unique. Without it, two refresh
tokens issued in the same second for the same user would be byte-identical,
which breaks rotation and reuse detection. (Covered by the
`refresh_tokens_are_unique_even_in_same_second` test in `jwt.rs`.)
- On refresh: validate signature + expiry, confirm the token exists and is not
revoked in the repository, mint a new access + refresh pair, revoke the old
refresh token (rotation).
## Technology Stack
### Token revocation: versioning + session management
### Backend (Axum)
- jsonwebtoken 9.x (JWT crate)
- bcrypt 0.15 (password hashing)
- mongodb 3.0 (refresh token storage)
- redis (optional, for access token blacklist)
Two mechanisms, both implemented:
### Client (React Native + React)
- AsyncStorage (token storage)
- axios (API client with JWT interceptor)
- PBKDF2 (password derivation)
- AES-256-GCM (data encryption)
1. **`token_version` (global kill-switch).** Stored on the `User` document,
incremented on password change and password recovery. The JWT middleware
(`middleware/auth.rs`) rejects any access token whose `token_version` is
stale against the user's current value. A short-lived `TokenVersionCache`
avoids hitting Mongo on every request.
2. **Session management.** Refresh tokens are tracked as sessions
(`GET /api/sessions`, `DELETE /api/sessions/:id`, `DELETE /api/sessions/all`)
so a user can list and revoke individual logins.
---
### Password handling
## Implementation Timeline
- **PBKDF2** (not bcrypt — the original ADR mentioned bcrypt), via the `pbkdf2`
crate with a per-password random salt. (`backend/src/auth/password.rs`)
- Under the ZK model, what the server hashes is the **auth secret** — a
base64'd PBKDF2 derivation of the user's password produced client-side — not
the raw password. The raw password never leaves the browser. See
[`encryption.md`](../product/encryption.md) §2.
- **Week 1**: Basic JWT (login, register, middleware)
- **Week 1-2**: Refresh tokens (storage, rotation)
- **Week 2**: Token revocation (blacklist, versioning)
- **Week 2-3**: Password recovery (recovery phrases)
- **Week 3**: Family access control (permissions)
- **Week 3-4**: Security hardening (rate limiting, HTTPS)
### Password recovery
**Total**: 3-4 weeks
Recovery is handled by the **zero-knowledge wrapped-DEK** mechanism, *not* by
the "client encrypts the recovery phrase with the password" scheme the original
ADR described. The client derives a recovery KEK from the recovery phrase,
unwraps the DEK from the recovery-wrapped form, and re-wraps it under the new
password. See [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md)
§"Phase 2: Wrapped-DEK Recovery". The server bumps `token_version` on recovery,
invalidating all prior tokens.
---
### What protects what
## Next Steps
Auth + the ciphertext store are defended server-side; plaintext health data is
never on the server to protect:
1. Implement basic JWT service in Axum
2. Create MongoDB schema for users and refresh tokens
3. Implement login/register/refresh/logout handlers
4. Create JWT middleware for protected routes
5. Implement token revocation (blacklist + versioning)
6. Integrate password recovery (from encryption.md)
7. Implement family access control (permissions)
8. Test entire authentication flow
9. Create client-side authentication (React Native + React)
| Layer | Mechanism |
|---|---|
| Password / auth secret | PBKDF2 + per-password salt |
| Tokens | HS256 signature, short access TTL, refresh rotation + `jti` reuse detection |
| Revocation | `token_version` (global) + session list (per-login) |
| Brute force | Account lockout (5 attempts → exponential backoff) + rate limiting |
| Forensics | Audit logging (auth attempts, authz checks) |
> Reference: `backend/src/auth/`, `backend/src/security/`, `backend/src/middleware/`.
## Consequences
### Positive
- Stateless access tokens scale horizontally (no DB lookup per request except
the cached version check).
- `token_version` gives instant global revocation on password change/recovery
without maintaining a blocklist of outstanding tokens.
- Refresh rotation + `jti` detects token theft: a rotated-away token being
presented again signals reuse.
### Negative / costs
- `token_version` revocation is **coarse** — it kills *all* of a user's
sessions at once. Per-session revocation is only available via the session
list (refresh-token deletion).
- HS256 with a shared secret means the secret is sensitive infrastructure; a
key rotation requires invalidating all tokens (acceptable for the deployment
model, but worth noting vs. asymmetric RS/ES keys).
## Open items
- ~~Remove `backend/src/auth/claims.rs` (dead `AccessClaims`) or wire it up
intentionally.~~ **Done** — the orphaned file has been deleted (issue #5,
PR #<to fill after open>). The live `Claims` / `RefreshClaims` structs in
`backend/src/auth/jwt.rs` are the only ones.
- When issue #3 (multi-person sharing) is decided, revisit whether family /
share authorization belongs in the JWT or is enforced per-request against a
shares collection.
## History (original 2026-02-14 decision, superseded where noted)
The original ADR proposed: JWT with refresh tokens; bcrypt; optional Redis for
access-token blacklists; recovery by client-encrypting the recovery phrase with
the password; and `family_id`/`permissions` claims with parent/child/elderly
role matrices. Of these, only **JWT with refresh tokens + rotation** was built
as described. Password hashing uses PBKDF2 (not bcrypt), there is no Redis,
recovery uses the wrapped-DEK scheme (not phrase-encryption), and the family/
permission claims were never wired into the real `Claims`. This section is kept
as a record of the original reasoning; the sections above are authoritative.
---
## References
- Code: `backend/src/auth/jwt.rs`, `backend/src/auth/password.rs`,
`backend/src/middleware/auth.rs`, `backend/src/auth/token_version_cache.rs`,
`backend/src/models/refresh_token.rs`, `backend/src/models/session.rs`
- Related ADRs: [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md),
[`multi-person-sharing.md`](./multi-person-sharing.md) (draft)
- Product doc: [`encryption.md`](../product/encryption.md)
- Related issues: [#3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3),
[#4](https://gitea.soliverez.com.ar/alvaro/normogen/issues/4)
- [Comprehensive JWT Research](./2026-02-14-jwt-authentication-research.md)
- [Normogen Encryption Guide](../encryption.md)
- [JWT RFC 7519](https://tools.ietf.org/html/rfc7519)
- [Axum JWT Guide](https://docs.rs/axum/latest/axum/)
- [OWASP JWT Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html)

View file

@ -1,336 +0,0 @@
# ADR: Multi-Person Sharing Under Zero-Knowledge
**Status**: Decided (not yet implemented)
**Date**: 2026-07-18
**Discussion**: [issue #3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3)
**Related**: [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md) (the single-user ZK model this extends)
> Records the design decision and its rationale ahead of implementation,
> following the pattern of `zero-knowledge-encryption.md`. The five original
> open questions were resolved on 2026-07-18 (see "Open questions" section).
> Two deferred items remain (pubkey verification mechanism; admin re-key
> quorum) — neither blocks implementation. All crypto primitives named here
> are available in the browser's Web Crypto API.
---
## Context
Normogen's core product vision (see
[`docs/product/PERSONA_AND_FAMILY_MANAGEMENT.md`](../product/PERSONA_AND_FAMILY_MANAGEMENT.md))
is multi-person health data: a parent manages a child's medications and shares
them with a spouse or caregiver. The existing zero-knowledge model
(`zero-knowledge-encryption.md`) is single-user: **all of a user's data is
encrypted under one DEK that only that user can unwrap.** There is no mechanism
for a second user to decrypt any of it. As called out in issue #3, the current
`Share` model + `/api/shares` routes store ACL-style permission records but
cannot grant decryption access to another account — every medication blob is
under the owner's DEK, and the recipient has a different one.
The product requirements that constrain this design (confirmed in issue #3):
1. **Parents and spouses have separate logins**, and may belong to *different*
families. Divorced parents with their own families must be able to share the
profile of their common child **and nothing else**.
2. **Recipients always have Normogen accounts.** (One-way document dumps to
non-accounts — e.g. a physician via an encrypted PDF — is a separate,
simpler feature, out of scope here.)
3. **Revocation is a hard requirement.** Cutting off a caregiver must take
effect.
4. **The server operator is fully untrusted.** Key exchange cannot rely on the
server brokering symmetric secrets.
Additional use cases from issue #3 that the model must accommodate:
- **Profile graduation.** A child profile eventually becomes an independent
user with their own login, who may then revoke the parents' access.
- **Pets.** A profile may be an animal (medications, labs, etc.), not a human.
## Decision
Implement **per-profile DEKs with an X25519 asymmetric envelope** for sharing,
keeping the server a fully blind store. Concretely:
### 1. Key hierarchy
```
password KEK ──wraps──▶ account DEK ──wraps each──▶ profile DEK ──encrypts──▶ profile data
│ (one per profile)
└──also wraps──▶ account X25519 private key
(account X25519 public key stored plaintext)
```
- **Account DEK**: random AES-256-GCM key, wrapped under the password KEK
(unchanged from today). At login the client unwraps it and holds it in
memory.
- **Profile DEK**: a *new* random AES-256-GCM key **per profile**. All data
belonging to a profile (its medications, appointments, etc.) is encrypted
under the profile DEK, not the account DEK. Each profile DEK is wrapped under
the account DEK and stored on the server, indexed by `profileId`.
- **Account X25519 keypair**: generated at registration. The **private** half
is wrapped under the account DEK (so it unlocks at login); the **public**
half is stored in plaintext — public keys are not secret.
This adds one tier to today's flat `account DEK → data`. Rationale in
Consequences (§3-tier vs flat).
### 2. Ownership and access
- Each **profile** has exactly one **owning account** at a time. The owner
holds the profile DEK wrapped to their account DEK (the *owner share*).
- **Shared access** = the profile DEK wrapped to another account's X25519
public key. Stored on the server as a *recipient share*, indexed by
`(profileId, recipientUserId)`.
- To **read** a shared profile, the recipient's client (at login) unwraps their
X25519 private key, ECDH-derives a wrapping key against the share's ephemeral
public key, and unwraps the profile DEK.
This is the standard asymmetric-envelope pattern (cf. Signal, `age`,
Tarsnap). The server only ever stores wrapped blobs and public keys.
### 3. Sharing flow (owner → recipient)
Client-side only, server untrusted:
1. Owner's client looks up the **recipient's X25519 public key**
(`GET /api/users/:id/public-key`, new).
2. Owner's client unwraps the target profile's DEK (via their account DEK).
3. Generate an **ephemeral X25519 keypair** for this share.
4. ECDH(ephemeral priv, recipient pub) → AES-GCM **wrapping key**.
5. Wrap the profile DEK under that wrapping key → `{ciphertext, iv, ephemeralPubKey}`.
6. `POST /api/profiles/:id/shares` with `{recipientUserId, wrappedDek, ephemeralPubKey, permissions, expiresAt?}`.
7. Server stores the share record. It cannot decrypt it.
### 4. Revocation
Two layers, in order of strength:
- **Soft revoke** (cheap): delete or deactivate the recipient share record. The
recipient's client no longer receives the wrapped DEK from the server, so it
cannot decrypt **new reads**. Caveat: a malicious client that cached the
profile DEK before revocation keeps decrypting data it already fetched.
- **Hard revoke / re-key** (strong): owner generates a **new profile DEK**,
re-encrypts all of the profile's data under it, re-wraps to the owner share
and to all *still-valid* recipient shares. A revoked recipient's cached old
DEK now fails to decrypt any future fetch. Required for the "kid revokes
parent" graduation case where you must assume the parent may have cached
data.
Hard revocation is the answer to requirement (3). Re-keying is expensive
(linear in profile data size) so it is opt-in / triggered explicitly, not the
default for every revoke.
### 5. Profile model changes
To support pets and graduation, the current `role: "patient"` string becomes:
```
kind: "human" | "pet" // distinguishes people from animals
relationship: string // freeform: "self", "child", "spouse", "pet", ...
```
Ownership-transfer (graduation) is **not new crypto** — it is the existing
share machinery plus an `owner_account_id` field swap:
1. New adult account (the graduated child) generates its X25519 keypair.
2. Old owner wraps the profile DEK to the new account's pubkey as a share, and
marks that share as the new **owner share**.
3. Old owner's access is downgraded to (or removed as) a normal recipient share.
4. The new owner can revoke any remaining parent shares via soft/hard revoke.
### 5b. Use case: elderly parent cared for by children
A key motivation for the profile/account split is that **profile ownership and
account login are independent.** The elderly-care scenario has three setups,
all covered:
- **Parent self-manages, shares to children.** Parent owns their own account
and `self` profile; creates recipient shares to each child. Standard
share-to-another-account flow. Any party can revoke.
- **Managing child holds a dedicated parent account.** A separate account
exists for the parent (the identity of record), but the managing child holds
or controls the password and shares the profile to siblings. Cleaner than
embedding the parent as a profile inside the child's account, and preserves
the "take over later" path: if the parent becomes able (or, conversely,
passes away), it's the same ownership machinery.
- **Parent modeled as a profile inside the managing child's account.** No
separate login for the parent; the child owns the profile and shares it.
Simplest, but the parent has no independent identity and cannot revoke
themselves. Recommend against this when the parent is or may become an
independent actor.
**Death/incapacity of the sole-account owner** is the one wrinkle that needs
an explicit decision: see "Permissions and the admin-share question" below.
### 5c. Permissions and the admin-share question
The `permissions` list on a recipient share (Open question 3) raises a
sub-decision: **does an `admin` permission let a recipient act as a quasi-owner
without the original owner?** Specifically: re-share the profile, re-key it
(hard revoke), and add/remove other shares.
This matters for two scenarios:
- **Graduation in reverse / death of the owner.** If the parent owns the
account and becomes incapacitated, no child can write, re-share, or revoke —
those require the owner's account DEK, which is gone with the password.
- **Operational delegation.** A primary caregiver wants to delegate admin
tasks without sharing their password.
**Decision: support an `admin` permission tier.** An admin share can re-share,
re-key, and manage other shares on a profile. This keeps the owner-DEK model
intact (the owner share is still special — it can *transfer* ownership, which
admin cannot) while handling the death/incapacity and delegation cases without
key escrow. Hard revocation (re-key) by an admin rotates the profile DEK and
re-wraps to all *still-valid* shares, same as owner-initiated re-key.
> ❓ **Open sub-item:** when an admin re-keys, do we require a quorum or a
> second admin's consent? For MVP, no — any admin can act alone, matching how
> ownership transfer works today. Revisit if multi-party control becomes a
> requirement.
### 6. What happens to the current `Share` model
The existing `backend/src/models/share.rs` is **resource-level ACL**
(`resource_type`, `resource_id`, `permissions`) and predates encryption. It is
incompatible with this design, which shares at the **profile** level via keys,
not at the resource level via permissions.
Per issue #3, profile-level sharing covers the family/caregiver need; the
"share one document" need is handled separately (physician PDF feature). So:
- The current `Share` model and `/api/shares` routes are **retained for now**
but treated as a placeholder (issue #3). They will be either removed or
repurposed; this ADR does not depend on them.
- The new recipient-share records live in a **new collection** (provisional
name `profile_shares`) keyed by `(profileId, recipientUserId)`. Storing
wrapped DEKs, not ACLs.
### 7. Server surface (new endpoints, indicative)
- `GET /api/users/:id/public-key` → recipient's X25519 public key (plaintext).
- `GET /api/profiles/me/dek` → the account-wrapped profile DEK (owner unlock).
- `POST /api/profiles` → create profile: client generates profile DEK, wraps to
account DEK, sends wrapped form. Returns `profileId`.
- `GET /api/profiles/:id/shares` → list recipient shares for a profile.
- `POST /api/profiles/:id/shares` → create a recipient share (wrapped DEK).
- `DELETE /api/profiles/:id/shares/:shareId` → soft revoke.
- `POST /api/profiles/:id/rekey` → hard revoke / rotate the profile DEK.
All endpoints store/return opaque blobs or public keys; none expose a DEK or a
private key.
---
## Consequences
### Positive
- **Satisfies all four product constraints.** Per-profile keys enable the
divorced-parents case; hard revoke enables the revocation requirement; the
X25519 envelope keeps the server untrusted.
- **Profiles become first-class subjects of care** — owners, shares, pets,
graduation all fall out naturally.
- **Server stays a blind store.** No new backend crypto; still no `aes`/crypto
crates in `Cargo.toml`.
- **Composition with existing recovery.** The account DEK is still recovered
via the existing password/recovery wrapped-DEK flow; profile DEKs are wrapped
to the account DEK, so recovering the account recovers all profiles.
### Negative / costs
- **Adds a key tier.** Three levels instead of one. Complexity in the client
crypto module (`crypto/keys.ts` will grow X25519 + wrap-to-recipient logic).
- **Migration.** Today all data is under one account DEK. Splitting into
per-profile DEKs requires reading, decrypting, re-encrypting, and re-uploading
every document. **There is no real user data yet** (per
`zero-knowledge-encryption.md`), so this is cheap **now** and expensive
later — strong argument for doing the re-key before launch.
- **Hard revocation is expensive.** Re-keying a profile is O(profile data
size). Mitigation: soft revoke by default, hard revoke only when the owner
explicitly requires it (e.g. graduation, suspected compromise).
- **Recipient must have an account** (constraint 2). No magic-link sharing in
this design — deferred to the physician-PDF feature.
### Security notes
- **Ephemeral keys per share** prevent cross-share correlation and give
forward secrecy within a share's lifetime (re-keying rotates the DEK).
- **ECDH + AES-GCM** is the Web Crypto idiom; X25519 keys are 32 bytes, shares
are small.
- **Public keys are not authenticated by the server.** The owner must trust
that `GET /users/:id/public-key` returns the right key. Since the server is
untrusted, this is an accepted risk: a malicious server could swap the key
and force a re-share, but cannot decrypt. ❓ Decide whether to add a key
fingerprint the recipient verifies out-of-band.
## Open questions (❓) — resolved 2026-07-18
1. **Key hierarchy depth.****Decided: 3-tier** (account DEK → profile DEK →
data). Rationale: account DEK is recovered via the existing wrapped-DEK
recovery flow, so password change only re-wraps the account DEK (O(1)),
not every profile DEK. Profile DEKs are re-wrapped only on profile-level
events (share/revoke/rekey), which are rare.
2. **Public-key trust.** ✅ **Decided: accept key-swap as DoS-only for now.**
A malicious server can swap a recipient's public key and force a failed
share (DoS) but cannot decrypt. **Future enhancement (not blocking):** add
a mechanism for out-of-band pubkey verification. Candidate approaches to
evaluate when we get there:
- **Key fingerprint / safety number.** Display a short hash of a user's
pubkey that two parties can compare out-of-band (cf. Signal safety
numbers). Cheap, requires user action.
- **Pubkey in recovery phrase / account export.** Derive or include the
pubkey fingerprint in something the user already verifies.
- **Web-of-trust / TOFU.** First-seen pubkey is pinned client-side; a change
triggers a user-visible warning.
These are additive — none changes the core envelope design, so they can
ship later without a migration.
3. **Share record schema.** ✅ **Decided: `profile_shares` collection, schema
as proposed, permissions included.** Fields:
`{profileId, recipientUserId, ephemeralPubKey, wrappedDek, iv, permissions,
expiresAt, active, createdAt}`. `permissions` is a list (e.g.
`["read"]`, `["read","write"]`, `["read","write","admin"]`); the server
enforces write/admin server-side by checking the share record (the owner
always implicitly has admin via the owner share). See §"Permissions and
the admin-share question" below for the semantics of `admin`.
4. **Migration.****Decided: no migration — wipe the database.** There is
no real user data, and the re-key cost is unjustified. Phase A therefore
includes a clean-cutover step rather than a per-document migration. (This
also removes any legacy single-DEK documents that would otherwise need
special-casing.)
5. **Existing `Share` model fate.** ✅ **Decided: remove entirely.**
`backend/src/models/share.rs`, the `/api/shares` routes, and the
`permissions/check` endpoint are deleted; the new `profile_shares`
collection and `/api/profiles/:id/shares` endpoints replace them. The
future per-resource physician-share feature will be built from scratch on
its own model when prioritized (it's a different access pattern — one-way
document dump, not ongoing profile access).
## Phasing (proposal)
- **Phase A — key tiers without sharing.** Introduce per-profile DEKs and the
account X25519 keypair; migrate existing data; no sharing endpoints yet.
Unblocks multi-profile-within-account (the parent/child single-login case).
- **Phase B — profile sharing.** The `POST/GET/DELETE /profiles/:id/shares`
endpoints and soft revoke.
- **Phase C — hard revoke / re-key.** The `/profiles/:id/rekey` endpoint and
owner-driven DEK rotation.
- **Phase D — graduation / ownership transfer.** Owner-share swap UI + endpoint.
Each phase is independently shippable and independently testable.
---
## References
- Issue: [#3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3)
- Predecessor ADR: [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md)
- Current client crypto: `web/normogen-web/src/crypto/keys.ts`, `cipher.ts`
- Profile model: `backend/src/models/profile.rs`
- (Incompatible) current share model: `backend/src/models/share.rs`
- Web Crypto API: X25519 via `generateKey({name:"ECDH", namedCurve:"X25519"})`,
`deriveBits` for ECDH, `wrapKey`/`unwrapKey` or manual AES-GCM for the envelope.

View file

@ -1,13 +1,5 @@
# Encryption.md Update Summary
> **ARCHIVED 2026-07-18.** This was a changelog note for a 2026-03-09 rewrite of
> `docs/product/encryption.md` that itself became stale (it describes ZK
> encryption as "planned" when it is in fact implemented). Kept here for
> history only. The current, accurate encryption doc is
> [`docs/product/encryption.md`](../product/encryption.md), and the canonical
> design is [`docs/adr/zero-knowledge-encryption.md`](../adr/zero-knowledge-encryption.md).
> See issue #4.
**Date**: 2026-03-09
**File**: docs/product/encryption.md
**Update**: Added Rust implementation examples and current status

View file

@ -1,16 +1,10 @@
# Persona and Family Management - Product Definition
**Document Version**: 1.1
**Date**: 2026-07-18 (reconciled with code; original draft 2026-03-09)
**Status**: Vision doc — implementation status section corrected to match code
**Document Version**: 1.0
**Date**: 2026-03-09
**Status**: Draft - For Refinement
**Purpose**: Consolidate all current information about persona and family management features for further product refinement
> **How to read this doc.** Sections marked **Vision** describe the target
> product and are still valid for discussion. Sections marked **Today (code)**
> describe what is actually implemented and have been verified against the
> codebase as of 2026-07-18. Where the two diverge, the gap is called out and
> linked to the relevant Forgejo issue.
---
## 📋 Table of Contents
@ -41,28 +35,6 @@ Normogen supports **multi-person health data management** through a persona and
---
## Current reality (as of 2026-07-18)
This section corrects the earlier "Current Implementation Status" section below,
which described an earlier state. Verified against the code:
| Capability | Status | Notes |
|---|---|---|
| Single profile per user account | ✅ Done | `GET/PUT /api/profiles/me`; profile is auto-created as `profile_{user_id}` at registration. Display name is client-encrypted. |
| Multi-profile (child/dependent) within one account | ❌ Not built | `ProfileRepository::find_by_user_id` returns a single profile; no create/list/switch endpoints. `profileId` can be tagged onto medications but there is no backing entity beyond the owner's. |
| Family groups | ❌ Not built | `Family` model exists but has **no handler and no routes**. JWT carries `family_id` but nothing populates or checks it. |
| Cross-user sharing (spouse, caregiver) | ❌ **Blocked by design** | `/api/shares` routes exist but cannot grant decryption access — all data is encrypted under the owner's DEK and there is no key-distribution mechanism. See **[issue #3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3)**. |
| Caregiver roles / permissions | ❌ Not built | `permissions` field exists on Profile and in JWT claims but is not enforced. |
| Zero-knowledge encryption of health data | ✅ Done | Client-side Web Crypto, wrapped-DEK recovery. See [`encryption.md`](encryption.md). |
**Implication for the family/caregiver personas below:** the single biggest
open question is architectural — *how does sharing work under zero-knowledge?*
That must be decided before family/caregiver features can be implemented. Until
then, the realistic near-term scope is **multi-profile within a single account**
(one login managing several people's data, no cross-account sharing).
---
## User Personas
### Primary: Privacy-Conscious Individual
@ -195,12 +167,7 @@ User Account: jane.doe@example.com (Jane)
---
## Current Implementation Status (superseded — see "Current reality" above)
> The detail below was written 2026-03-09 against an earlier state and
> overstates what existed then. It is retained for history. For the accurate
> current picture, read the **Current reality** section near the top of this
> document, and [`encryption.md`](encryption.md) for encryption.
## Current Implementation Status
### ✅ Implemented (Backend)
@ -628,23 +595,16 @@ if profile.role == "child" || profile.age < 18 {
### Encryption
> **Correction (2026-07-18):** the bullets below described a planned
> server-side scheme that was never built. The shipped system is
> **client-side** zero-knowledge via the browser's Web Crypto API with a
> wrapped-DEK model. The server has no crypto code at all — it is a blind
> store. Profile/medication/appointment data is encrypted in the browser
> before upload and the server cannot read any of it. Full details:
> [`encryption.md`](encryption.md) and
> [`adr/zero-knowledge-encryption.md`](../adr/zero-knowledge-encryption.md).
**Zero-Knowledge Encryption**:
- Profile names are encrypted (AES-256-GCM)
- Family names are encrypted
- Only users with correct password can decrypt
- Server cannot see profile names
**What's actually encrypted (client-side, today):**
- Medication data blob (name, dosage, frequency, route, notes, …)
- Appointment data blob (title, provider, date/time, …)
- Profile display name
**What stays plaintext (queryable by the server):**
- IDs (`profileId`, `medicationId`, `userId`, `appointmentId`)
- Medication `active` flag, appointment `status`, `doseSchedule`, timestamps
**Encrypted Fields**:
- `profile.name` - Person's name
- `family.name` - Family group name
- Medication data (when implemented)
---

File diff suppressed because it is too large Load diff

View file

@ -16,7 +16,6 @@ import SaveIcon from '@mui/icons-material/Save';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import { useProfileStore, useAuthStore } from '../../store/useStore';
import { ProfileSharing } from './ProfileSharing';
export const ProfileEditor: FC = () => {
const {
@ -151,11 +150,7 @@ export const ProfileEditor: FC = () => {
) : (
<Stack direction="row" alignItems="center" spacing={1}>
<Typography variant="h6">{active.name || user?.username || '—'}</Typography>
{active.is_shared ? (
<Chip size="small" label="shared with you" color="info" variant="outlined" />
) : (
<Button size="small" onClick={() => setEditing(true)}>Edit</Button>
)}
</Stack>
)}
</Box>
@ -203,16 +198,6 @@ export const ProfileEditor: FC = () => {
)}
</Box>
</Box>
{active.is_shared ? (
<Box>
<Typography variant="caption" color="text.secondary">Owner</Typography>
<Typography variant="body2">{active.owner_account_id}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
You have read-only access to this shared profile.
</Typography>
</Box>
) : (
<>
<Box>
<Typography variant="caption" color="text.secondary">Account</Typography>
<Typography variant="body2">{user?.email}</Typography>
@ -232,15 +217,6 @@ export const ProfileEditor: FC = () => {
</Box>
</>
)}
</>
)}
{/* Owner-only sharing UI. */}
{!editing && !active.is_shared && (
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider' }}>
<ProfileSharing profileId={active.profile_id} />
</Box>
)}
</Stack>
</CardContent>
</Card>

View file

@ -1,189 +0,0 @@
import { useEffect, useState, type FC } from 'react';
import {
Box,
Button,
CircularProgress,
Alert,
IconButton,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
Stack,
TextField,
Typography,
} from '@mui/material';
import PersonAddIcon from '@mui/icons-material/PersonAdd';
import PersonRemoveIcon from '@mui/icons-material/PersonRemove';
import KeyIcon from '@mui/icons-material/Key';
import { useProfileStore } from '../../store/useStore';
/** Owner-side sharing UI for a single owned profile: lists the recipients the
* profile is shared with (with revoke buttons) and an "add recipient by
* email" field. Shown only for profiles the current user owns. Recipients
* get the profile via /profiles/shared-with-me + the ECDH-wrapped DEK. */
export const ProfileSharing: FC<{ profileId: string }> = ({ profileId }) => {
const {
profileShares,
loadProfileShares,
shareProfile,
revokeShare,
rekeyProfile,
error,
clearError,
} = useProfileStore();
const [recipientEmail, setRecipientEmail] = useState('');
const [busy, setBusy] = useState(false);
const [localError, setLocalError] = useState('');
useEffect(() => {
loadProfileShares(profileId);
}, [profileId, loadProfileShares]);
const shares = profileShares[profileId] ?? [];
const handleAdd = async () => {
setBusy(true);
setLocalError('');
try {
await shareProfile(profileId, recipientEmail.trim());
setRecipientEmail('');
} catch (e: any) {
setLocalError(e?.message || 'Failed to share');
} finally {
setBusy(false);
}
};
const handleRevoke = async (recipientUserId: string) => {
if (!confirm('Revoke this share? The recipient will lose access immediately.')) return;
setBusy(true);
try {
await revokeShare(profileId, recipientUserId);
} catch {
/* surfaced via store error */
} finally {
setBusy(false);
}
};
const handleHardRevoke = async () => {
// Hard revoke = rotate the profile DEK. All currently-listed recipients
// are KEPT (re-wrapped to the new DEK); the owner removes specific
// recipients via the per-row revoke button above first if they want them
// gone at the key level. Expensive (re-encrypts all the profile's data),
// but resume-safe — partial runs can be retried.
const ok = confirm(
'Rotate this profile\'s encryption key?\n\n' +
'This re-encrypts ALL the profile\'s data (medications, appointments, ' +
'health stats) under a new key — it may take a moment. All currently-' +
'listed recipients keep access (re-wrapped to the new key). Anyone who ' +
'previously had access and was removed, or any cached copy of the old ' +
'key, will stop working.\n\nUse this if you suspect a key was compromised.',
);
if (!ok) return;
setBusy(true);
setLocalError('');
try {
const keepIds = shares.map((s) => s.recipient_user_id);
await rekeyProfile(profileId, keepIds);
} catch (e: any) {
setLocalError(
(e?.message || 'Re-key failed') +
' — you can retry; rows already re-encrypted are skipped.',
);
} finally {
setBusy(false);
}
};
return (
<Box>
<Typography variant="subtitle1" sx={{ mb: 1 }}>
Shared with
</Typography>
{(error || localError) && (
<Alert
severity="error"
sx={{ mb: 2 }}
onClose={() => {
setLocalError('');
clearError();
}}
>
{localError || error}
</Alert>
)}
{shares.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Not shared with anyone.
</Typography>
) : (
<List dense sx={{ mb: 2, bgcolor: 'background.paper', borderRadius: 1 }}>
{shares.map((s) => (
<ListItem key={s.recipient_user_id}>
<ListItemText
primary={s.recipient_email || s.recipient_user_id}
secondary={s.permissions.join(', ')}
/>
<ListItemSecondaryAction>
<IconButton
edge="end"
aria-label="revoke"
disabled={busy}
onClick={() => handleRevoke(s.recipient_user_id)}
>
<PersonRemoveIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
<Stack direction="row" spacing={1} alignItems="center">
<TextField
size="small"
fullWidth
type="email"
placeholder="recipient@example.com"
value={recipientEmail}
onChange={(e) => setRecipientEmail(e.target.value)}
/>
<Button
variant="contained"
startIcon={<PersonAddIcon />}
disabled={busy || !recipientEmail.trim()}
onClick={handleAdd}
>
{busy ? <CircularProgress size={24} /> : 'Share'}
</Button>
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
The recipient must have a Normogen account. They'll see this profile in their switcher.
</Typography>
<Box sx={{ mt: 3, pt: 2, borderTop: 1, borderColor: 'divider' }}>
<Button
size="small"
color="warning"
variant="outlined"
startIcon={<KeyIcon />}
disabled={busy}
onClick={handleHardRevoke}
>
{busy ? <CircularProgress size={20} /> : 'Rotate encryption key (hard revoke)'}
</Button>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Generates a new key and re-encrypts this profile's data. Use if a key
may have been compromised. Resume-safe you can retry on failure.
</Typography>
</Box>
</Box>
);
};
export default ProfileSharing;

View file

@ -1,23 +1,17 @@
import { useEffect, type FC } from 'react';
import { Box, Chip, CircularProgress, MenuItem, Select, Typography } from '@mui/material';
import { Box, CircularProgress, MenuItem, Select, Typography } from '@mui/material';
import { useProfileStore } from '../../store/useStore';
/** A compact dropdown for switching the active profile (the subject of care
* whose data the dashboard shows). Sits in the AppBar. Triggers `loadProfiles`
* + `loadSharedWithMe` on mount so owned AND shared profiles are ready. */
* on mount so the list + per-profile DEKs are ready. */
export const ProfileSwitcher: FC = () => {
const { profiles, activeProfileId, isLoading, loadProfiles, loadSharedWithMe, setActiveProfile } =
const { profiles, activeProfileId, isLoading, loadProfiles, setActiveProfile } =
useProfileStore();
useEffect(() => {
(async () => {
await loadProfiles();
// Fetch profiles shared TO me after owned profiles are loaded (the
// identity private key must be unwrapped first; loadSharedWithMe is a
// no-op if it isn't available yet).
await loadSharedWithMe();
})();
}, [loadProfiles, loadSharedWithMe]);
loadProfiles();
}, [loadProfiles]);
if (profiles.length === 0) {
return isLoading ? (
@ -42,21 +36,10 @@ export const ProfileSwitcher: FC = () => {
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.5)' },
'.MuiSvgIcon-root': { color: 'common.white' },
}}
renderValue={(value) => {
const p = profiles.find((x) => x.profile_id === value);
return p ? (p.name || p.relationship || p.profile_id) : '';
}}
>
{profiles.map((p) => (
<MenuItem key={p.profile_id} value={p.profile_id}>
<span>{p.name || p.relationship || p.profile_id}</span>
{p.is_shared && (
<Chip
size="small"
label="shared"
sx={{ ml: 1, height: 18, fontSize: '0.7rem' }}
/>
)}
{p.name || p.relationship || p.profile_id}
</MenuItem>
))}
</Select>

View file

@ -20,8 +20,6 @@ import {
generateProfileDek,
wrapProfileDek,
unwrapProfileDek,
wrapProfileDekToRecipient,
unwrapProfileDekFromShare,
setProfileDek,
getProfileDek,
clearProfileDeks,
@ -284,52 +282,3 @@ describe('per-profile DEK isolation (Phase A2)', () => {
expect(getProfileDek('profile_child')).toBeNull();
});
});
describe('profile sharing: X25519 envelope (Phase B)', () => {
it('owner wraps a profile DEK to a recipient, who unwraps it', async () => {
if (!(await x25519Supported())) {
console.log('[crypto] skipped (X25519 unsupported in this runtime)');
return;
}
// Two accounts, each with their own identity keypair.
const owner = await generateIdentityKeyPair();
const recipient = await generateIdentityKeyPair();
// A profile DEK the owner created (would normally be wrapped under the
// owner's account DEK too — irrelevant for this test).
const profileDek = await generateProfileDek();
// Owner wraps the profile DEK to the recipient's public key. Internally
// this derives a fresh ephemeral keypair + ECDH(recipient pub, ephemeral
// priv) and AES-GCM-wraps the DEK under the shared secret.
const envelope = await wrapProfileDekToRecipient(
profileDek,
recipient.publicKey,
owner.privateKey, // not actually used by wrap (only recipient pub matters)
);
expect(envelope.ephemeralPublicKey).not.toBe('');
expect(envelope.wrappedProfileDek.data).not.toBe('');
// Recipient unwraps with their private key + the envelope's ephemeral pub.
const recoveredDek = await unwrapProfileDekFromShare(
envelope.wrappedProfileDek,
envelope.ephemeralPublicKey,
recipient.privateKey,
);
expect(recoveredDek).toBeDefined();
// The recovered DEK encrypts/decrypts the same data the original did.
const data = await encrypt('shared-medication', profileDek);
expect(await decrypt(data, recoveredDek)).toBe('shared-medication');
// A *different* recipient's private key cannot unwrap the share.
const stranger = await generateIdentityKeyPair();
await expect(
unwrapProfileDekFromShare(
envelope.wrappedProfileDek,
envelope.ephemeralPublicKey,
stranger.privateKey,
),
).rejects.toThrow();
});
});

View file

@ -14,9 +14,6 @@ export {
generateProfileDek,
wrapProfileDek,
unwrapProfileDek,
wrapProfileDekToRecipient,
unwrapProfileDekFromShare,
type SharedProfileDekEnvelope,
setEncKey,
getEncKey,
clearEncKey,

View file

@ -147,97 +147,6 @@ export async function unwrapProfileDek(
return unwrapDek(payload, accountDek);
}
// ---------------------------------------------------------------------------
// Profile sharing: X25519 envelope (Phase B).
//
// To share a profile, the owner generates a fresh ephemeral X25519 keypair,
// ECDH-derives a shared secret from (ephemeral private, recipient public),
// uses it as an AES-GCM wrapping key, and wraps the profile DEK. The server
// stores the wrapped DEK + the ephemeral public key (plaintext). The recipient
// ECDH-derives the same shared secret from (their private, ephemeral public)
// and unwraps the profile DEK. See docs/adr/multi-person-sharing.md §3.
// ---------------------------------------------------------------------------
/** Shape returned by the owner-side share wrapping. */
export interface SharedProfileDekEnvelope {
/** base64 ephemeral X25519 public key (send to the server plaintext). */
ephemeralPublicKey: string;
/** Profile DEK wrapped under the ECDH-derived key. */
wrappedProfileDek: CipherPayload;
}
/** Import a base64-raw X25519 public key for ECDH. */
async function importX25519Public(publicKeyB64: string): Promise<CryptoKey> {
const raw = Uint8Array.from(atob(publicKeyB64), (c) => c.charCodeAt(0));
return crypto.subtle.importKey(
'raw',
raw as BufferSource,
{ name: 'ECDH', namedCurve: 'X25519' },
true,
[],
);
}
/** Owner: wrap a profile DEK to a recipient's identity public key. Generates
* a fresh ephemeral keypair per share. Requires the owner's identity private
* key (in-memory via getIdentityPrivate()). */
export async function wrapProfileDekToRecipient(
profileDek: CryptoKey,
recipientPublicKeyB64: string,
myIdentityPrivate: CryptoKey,
): Promise<SharedProfileDekEnvelope> {
// Fresh ephemeral keypair for this share (forward secrecy within its lifetime).
const ephemeral = (await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'X25519' },
true,
['deriveBits'],
)) as CryptoKeyPair;
const recipientPub = await importX25519Public(recipientPublicKeyB64);
// ECDH(ephemeral private, recipient public) -> shared secret -> AES-GCM key.
const sharedBits = await crypto.subtle.deriveBits(
{ name: 'ECDH', public: recipientPub },
ephemeral.privateKey,
256,
);
const wrapKey = await crypto.subtle.importKey(
'raw',
sharedBits,
{ name: 'AES-GCM' },
false,
['encrypt', 'decrypt'],
);
const wrappedProfileDek = await wrapDek(profileDek, wrapKey);
const rawEphemeralPub = await crypto.subtle.exportKey('raw', ephemeral.publicKey);
return {
ephemeralPublicKey: base64(new Uint8Array(rawEphemeralPub)),
wrappedProfileDek,
};
}
/** Recipient: unwrap a profile DEK from a share using the recipient's identity
* private key + the share's ephemeral public key. Inverse of
* wrapProfileDekToRecipient. Throws on tamper / wrong keys. */
export async function unwrapProfileDekFromShare(
wrapped: CipherPayload,
ephemeralPublicKeyB64: string,
myIdentityPrivate: CryptoKey,
): Promise<CryptoKey> {
const ephemeralPub = await importX25519Public(ephemeralPublicKeyB64);
const sharedBits = await crypto.subtle.deriveBits(
{ name: 'ECDH', public: ephemeralPub },
myIdentityPrivate,
256,
);
const wrapKey = await crypto.subtle.importKey(
'raw',
sharedBits,
{ name: 'AES-GCM' },
false,
['encrypt', 'decrypt'],
);
return unwrapDek(wrapped, wrapKey);
}
// ---------------------------------------------------------------------------
// High-level flows
// ---------------------------------------------------------------------------

View file

@ -20,12 +20,6 @@ import {
ProfileWireResponse,
CreateProfileRequest,
UpdateProfileRequest,
SharedProfileWireResponse,
CreateProfileShareRequest,
ProfileShareListing,
PublicKeyResponse,
RekeyRequest,
RekeyResponse,
HealthStatWireResponse,
UpdateHealthStatRequest,
EncryptedFieldWire,
@ -266,62 +260,6 @@ class ApiService {
await this.client.delete(`/profiles/${profileId}`);
}
// ---- Profile sharing (Phase B: X25519 envelope) ----
/** Public endpoint — recipient identity public key lookup by email. */
async getUserPublicKey(email: string): Promise<PublicKeyResponse> {
const response = await this.client.get<PublicKeyResponse>('/users/public-key', {
params: { email },
});
return response.data;
}
/** Profiles shared TO the current user. Carries ECDH-wrapped profile DEKs. */
async listSharedWithMe(): Promise<SharedProfileWireResponse[]> {
const response = await this.client.get<SharedProfileWireResponse[]>(
'/profiles/shared-with-me',
);
return response.data;
}
/** Owner: list who a profile has been shared with. */
async listProfileShares(profileId: string): Promise<ProfileShareListing[]> {
const response = await this.client.get<ProfileShareListing[]>(
`/profiles/${profileId}/shares`,
);
return response.data;
}
/** Owner: share a profile. The wrapped DEK + ephemeral pubkey are produced
* client-side via wrapProfileDekToRecipient. */
async createProfileShare(
profileId: string,
req: CreateProfileShareRequest,
): Promise<ProfileShareListing> {
const response = await this.client.post<ProfileShareListing>(
`/profiles/${profileId}/shares`,
req,
);
return response.data;
}
/** Owner: revoke a share (soft revoke — server deletes the share record). */
async deleteProfileShare(profileId: string, recipientUserId: string): Promise<void> {
await this.client.delete(`/profiles/${profileId}/shares/${recipientUserId}`);
}
/** Owner: hard revoke / rotate the profile DEK (Phase C). The client has
* already re-encrypted the data under a new DEK (client-side) and wrapped
* it to the owner account DEK + each kept recipient. Recipients omitted
* from `recipient_envelopes` are hard-revoked. */
async rekeyProfile(profileId: string, req: RekeyRequest): Promise<RekeyResponse> {
const response = await this.client.post<RekeyResponse>(
`/profiles/${profileId}/rekey`,
req,
);
return response.data;
}
// ---- Medications (zero-knowledge: opaque encrypted blobs) ----
async getMedications(profileId?: string): Promise<MedicationWireResponse[]> {

View file

@ -12,8 +12,6 @@ import {
generateProfileDek,
wrapProfileDek,
unwrapProfileDek,
wrapProfileDekToRecipient,
unwrapProfileDekFromShare,
encrypt as encryptRaw,
decrypt as decryptRaw,
setEncKey,
@ -21,7 +19,6 @@ import {
clearIdentityPrivate,
clearProfileDeks,
setIdentityPrivate,
getIdentityPrivate,
getEncKey,
getActiveProfileDek,
getActiveProfileId,
@ -40,10 +37,6 @@ import {
DrugInteraction,
AdherenceStats,
Profile,
ProfileShareListing,
MedicationWireResponse,
AppointmentWireResponse,
HealthStatWireResponse,
Appointment,
CreateAppointmentRequest,
UpdateAppointmentRequest,
@ -144,26 +137,6 @@ interface ProfileState {
relationship?: string;
}) => Promise<void>;
deleteProfile: (profileId: string) => Promise<void>;
/** Fetch profiles shared TO the current user and merge them into `profiles`,
* unwrapping each share's profile DEK via the recipient's identity private
* key. Called after loadProfiles on login/unlock. */
loadSharedWithMe: () => Promise<void>;
/** Owner: share a profile to a recipient by email. Fetches the recipient's
* identity public key, wraps the profile DEK to it via ECDH, and POSTs. */
shareProfile: (profileId: string, recipientEmail: string) => Promise<void>;
/** Owner: revoke a share (delete the share record server-side). */
revokeShare: (profileId: string, recipientUserId: string) => Promise<void>;
/** Shares the current user has created for a profile (for the owner UI). */
profileShares: Record<string, ProfileShareListing[]>;
loadProfileShares: (profileId: string) => Promise<void>;
/** Owner: hard revoke / rotate the profile DEK. Re-encrypts all the
* profile's data under a fresh DEK (client-side), then commits the
* rotation server-side. Recipients not in `keepRecipientUserIds` are
* hard-revoked (lose access at the key level). Expensive: O(data size). */
rekeyProfile: (
profileId: string,
keepRecipientUserIds: string[],
) => Promise<void>;
clearError: () => void;
}
@ -832,7 +805,6 @@ export const useProfileStore = create<ProfileState>()(
activeProfileId: null,
isLoading: false,
error: null,
profileShares: {},
loadProfiles: async () => {
const accountDek = getEncKey();
@ -1008,251 +980,6 @@ export const useProfileStore = create<ProfileState>()(
}
},
loadSharedWithMe: async () => {
// Recipient path: profiles shared TO the current user. Each share's
// profile DEK is wrapped under an ECDH-derived key; unwrap it with the
// identity private key, then decrypt the display name under the profile
// DEK. Merge into the profiles list with is_shared = true.
const myIdentityPrivate = getIdentityPrivate();
if (!myIdentityPrivate) {
// No identity key unlocked — can't unwrap shared profile DEKs. Silently
// skip; the owned-profile list still loads.
return;
}
try {
const shared = await apiService.listSharedWithMe();
const sharedProfiles: Profile[] = [];
for (const s of shared) {
// Skip if we already have the DEK in memory (avoid re-unwrapping).
let profileDek = getProfileDek(s.profile_id);
if (!profileDek) {
try {
profileDek = await unwrapProfileDekFromShare(
{ data: s.wrapped_profile_dek, iv: s.wrapped_profile_dek_iv },
s.ephemeral_public_key,
myIdentityPrivate,
);
setProfileDek(s.profile_id, profileDek);
} catch {
continue; // couldn't unwrap — skip this share
}
}
let name = '';
if (s.name_data && s.name_iv && profileDek) {
try {
name = await decryptRaw({ data: s.name_data, iv: s.name_iv }, profileDek);
} catch { name = ''; }
}
sharedProfiles.push({
profile_id: s.profile_id,
owner_account_id: s.owner_account_id,
name,
kind: s.kind,
relationship: s.relationship,
role: 'patient',
permissions: s.permissions,
created_at: s.created_at,
updated_at: s.created_at,
is_shared: true,
});
}
// Merge: replace any prior shared entries, keep owned ones intact.
const owned = get().profiles.filter((p) => !p.is_shared);
const merged = [...owned, ...sharedProfiles];
set({ profiles: merged });
} catch (error: any) {
// Non-fatal: owned profiles still work.
set({ error: error.message || 'Failed to load shared profiles' });
}
},
shareProfile: async (profileId, recipientEmail) => {
// Owner path: fetch recipient pubkey, wrap the profile DEK to it, POST.
const myIdentityPrivate = getIdentityPrivate();
const profileDek = getProfileDek(profileId);
if (!myIdentityPrivate) {
set({ error: 'No identity key unlocked — cannot share' });
throw new Error('No identity key');
}
if (!profileDek) {
set({ error: 'No profile key — switch to the profile first' });
throw new Error('No profile key');
}
set({ isLoading: true, error: null });
try {
const { identity_public_key: recipientPub } =
await apiService.getUserPublicKey(recipientEmail);
const envelope = await wrapProfileDekToRecipient(
profileDek,
recipientPub,
myIdentityPrivate,
);
await apiService.createProfileShare(profileId, {
recipient_email: recipientEmail,
ephemeral_public_key: envelope.ephemeralPublicKey,
wrapped_profile_dek: envelope.wrappedProfileDek.data,
wrapped_profile_dek_iv: envelope.wrappedProfileDek.iv,
permissions: ['read'],
});
// Refresh the share listing for this profile.
await get().loadProfileShares(profileId);
set({ isLoading: false });
} catch (error: any) {
set({
error: error.message || 'Failed to share profile',
isLoading: false,
});
throw error;
}
},
revokeShare: async (profileId, recipientUserId) => {
set({ isLoading: true, error: null });
try {
await apiService.deleteProfileShare(profileId, recipientUserId);
await get().loadProfileShares(profileId);
set({ isLoading: false });
} catch (error: any) {
set({
error: error.message || 'Failed to revoke share',
isLoading: false,
});
throw error;
}
},
loadProfileShares: async (profileId) => {
try {
const shares = await apiService.listProfileShares(profileId);
set((state) => ({
profileShares: { ...state.profileShares, [profileId]: shares },
}));
} catch (error: any) {
set({ error: error.message || 'Failed to load shares' });
}
},
rekeyProfile: async (profileId, keepRecipientUserIds) => {
// Phase C hard revoke. Re-encrypts all the profile's data client-side
// under a fresh DEK, then commits the rotation. Resume-safe per-row:
// if a row was already re-encrypted on a prior partial run, decrypt-
// with-old fails and we try decrypt-with-new; if that succeeds, the
// row is already on the new DEK and we skip it.
const accountDek = getEncKey();
const oldDek = getProfileDek(profileId);
const myIdentityPrivate = getIdentityPrivate();
if (!accountDek || !oldDek) {
set({ error: 'Unlock the profile first (no account/profile key)' });
throw new Error('No keys');
}
set({ isLoading: true, error: null });
try {
const newDek = await generateProfileDek();
// 3a. Re-encrypt every data row under the new DEK.
// medications + appointments: POST /:id with the new blob.
// health-stats: PUT /:id with the new blob.
const reencryptRow = async (
getBlob: () => Promise<{ data: string; iv: string } | null>,
reencrypt: (newBlob: { data: string; iv: string }) => Promise<void>,
) => {
const blob = await getBlob();
if (!blob) return;
let plaintext: string;
try {
plaintext = await decryptRaw(blob, oldDek);
} catch {
// Maybe already re-encrypted on a prior partial run — verify with
// the new DEK, and skip if so.
try {
await decryptRaw(blob, newDek);
return; // already on newDek
} catch {
throw new Error('row could not be decrypted with old or new DEK');
}
}
const newBlob = await encryptRaw(plaintext, newDek);
await reencrypt(newBlob);
};
const meds: MedicationWireResponse[] = await apiService.getMedications(profileId);
for (const m of meds) {
await reencryptRow(
async () => (m.encrypted_data?.data ? m.encrypted_data : null),
async (newBlob) => {
await apiService.updateMedication(m.medication_id, {
encrypted_data: newBlob,
});
},
);
}
const appts: AppointmentWireResponse[] = await apiService.getAppointments(
undefined,
profileId,
);
for (const a of appts) {
await reencryptRow(
async () => (a.encrypted_data?.data ? a.encrypted_data : null),
async (newBlob) => {
await apiService.updateAppointment(a.appointment_id, { encrypted_data: newBlob });
},
);
}
const stats: HealthStatWireResponse[] = await apiService.getHealthStats(profileId);
for (const s of stats) {
await reencryptRow(
async () => (s.encrypted_data?.data ? s.encrypted_data : null),
async (newBlob) => {
await apiService.updateHealthStat(s.id, { encrypted_data: newBlob });
},
);
}
// 3b. Build a fresh ECDH envelope per kept recipient.
const recipientEnvelopes = [];
if (myIdentityPrivate) {
const shares = get().profileShares[profileId] ?? [];
for (const s of shares) {
if (!keepRecipientUserIds.includes(s.recipient_user_id)) continue;
const { identity_public_key: recipientPub } = await apiService.getUserPublicKey(
s.recipient_email,
);
const env = await wrapProfileDekToRecipient(
newDek,
recipientPub,
myIdentityPrivate,
);
recipientEnvelopes.push({
recipient_user_id: s.recipient_user_id,
ephemeral_public_key: env.ephemeralPublicKey,
wrapped_profile_dek: env.wrappedProfileDek.data,
wrapped_profile_dek_iv: env.wrappedProfileDek.iv,
});
}
}
// 3c. Commit: rotate the owner share (new DEK wrapped under the account
// DEK) + recipient envelopes, hard-delete omitted recipients.
const ownerWrap = await wrapProfileDek(newDek, accountDek);
await apiService.rekeyProfile(profileId, {
new_wrapped_profile_dek: ownerWrap.data,
new_wrapped_profile_dek_iv: ownerWrap.iv,
recipient_envelopes: recipientEnvelopes,
});
// 3d. Swap the in-memory DEK and refresh the share listing.
setProfileDek(profileId, newDek);
await get().loadProfileShares(profileId);
set({ isLoading: false });
} catch (error: any) {
set({
error: error.message || 'Re-key failed (you can retry — already-re-encrypted rows are skipped)',
isLoading: false,
});
throw error;
}
},
clearError: () => set({ error: null }),
})),
);

View file

@ -371,72 +371,6 @@ export interface UpdateProfileRequest {
relationship?: string;
}
// A profile shared TO the current user (recipient view). Carries the
// per-share ECDH-wrapped DEK the recipient unwraps with their identity
// private key (not the account-wrapped form owned profiles use).
export interface SharedProfileWireResponse {
profile_id: string;
owner_account_id: string;
name_data: string;
name_iv: string;
kind: string;
relationship: string;
ephemeral_public_key: string;
wrapped_profile_dek: string;
wrapped_profile_dek_iv: string;
permissions: string[];
created_at: string;
}
// Request body for POST /profiles/:id/shares (owner wraps client-side).
export interface CreateProfileShareRequest {
recipient_email: string;
ephemeral_public_key: string;
wrapped_profile_dek: string;
wrapped_profile_dek_iv: string;
permissions?: string[];
expires_at?: string;
}
// A share the owner has created (listing view).
export interface ProfileShareListing {
profile_id: string;
recipient_user_id: string;
recipient_email: string;
permissions: string[];
created_at: string;
active: boolean;
}
// Phase C hard revoke: one fresh ECDH envelope per recipient the owner keeps.
export interface RecipientEnvelope {
recipient_user_id: string;
ephemeral_public_key: string;
wrapped_profile_dek: string;
wrapped_profile_dek_iv: string;
}
// Request body for POST /profiles/:id/rekey (hard revoke / rotate the DEK).
export interface RekeyRequest {
new_wrapped_profile_dek: string;
new_wrapped_profile_dek_iv: string;
recipient_envelopes: RecipientEnvelope[];
}
// Response from POST /profiles/:id/rekey.
export interface RekeyResponse {
profile_id: string;
wrapped_profile_dek: string;
wrapped_profile_dek_iv: string;
retained_recipient_user_ids: string[];
}
// Response from GET /users/public-key?email=...
export interface PublicKeyResponse {
user_id: string;
identity_public_key: string;
}
// Dose Log Types — match backend MedicationDose (camelCase serialization).
export interface DoseLog {
id?: string;
@ -477,10 +411,6 @@ export interface Profile {
permissions: string[];
created_at: string;
updated_at: string;
/** True if this profile is shared TO the current user (recipient view).
* Undefined/false for profiles the user owns. Set by the store when merging
* shared-with-me results. */
is_shared?: boolean;
}
// Error Types