Compare commits

..

No commits in common. "main" and "ci/fix-mongo-service-port-collision" have entirely different histories.

52 changed files with 2174 additions and 5762 deletions

1
.gitignore vendored
View file

@ -2,7 +2,6 @@
.env.local
.env.*.local
.forgejo-token
.zcode/
node_modules/
dist/
target/

View file

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

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

@ -1,9 +1,6 @@
use anyhow::Result;
use pbkdf2::{
password_hash::{
rand_core::OsRng, Error as PasswordHashError, PasswordHash, PasswordHasher,
PasswordVerifier, SaltString,
},
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Pbkdf2,
};
@ -18,24 +15,14 @@ impl PasswordService {
Ok(password_hash.to_string())
}
/// Verify a password against a stored PHC string.
///
/// Returns `Ok(true)` on a match, `Ok(false)` on a mismatch (wrong
/// password — an expected outcome, not an error), and `Err(...)` only for
/// genuine failures (unparseable hash, malformed input, etc.). Returning
/// `Ok(false)` for a wrong password lets the login handler distinguish
/// "invalid credentials" (401) from a real backend fault (500).
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
let parsed_hash = PasswordHash::new(hash)
.map_err(|e| anyhow::anyhow!("Failed to parse password hash: {}", e))?;
match Pbkdf2.verify_password(password.as_bytes(), &parsed_hash) {
Ok(()) => Ok(true),
// `Error::Password` is the password-hash crate's signal for a
// mismatch — the one expected "failure" during normal auth.
Err(PasswordHashError::Password) => Ok(false),
Err(e) => Err(anyhow::anyhow!("Password verification failed: {}", e)),
}
Pbkdf2
.verify_password(password.as_bytes(), &parsed_hash)
.map(|_| true)
.map_err(|e| anyhow::anyhow!("Password verification failed: {}", e))
}
}

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

@ -20,8 +20,6 @@ use mongodb::bson::DateTime;
pub struct AppointmentListQuery {
/// Filter by status (upcoming/completed/cancelled). Optional.
pub status: Option<String>,
/// Filter by profile id. Optional.
pub profile_id: Option<String>,
}
pub async fn create_appointment(
@ -65,175 +63,64 @@ 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())
.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" })),
));
}
Err(e) => {
tracing::error!("get_appointment failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
// Authorization: direct owner (user_id == caller) OR share-gate on the
// appointment's profile (admits an active share recipient).
if appt.user_id != claims.sub {
crate::handlers::profile_share::authorize_profile_read(&state, &claims, &appt.profile_id)
.await?;
match repo.find_by_appointment_id(&id).await {
Ok(Some(appt)) => Ok(Json(appt.into())),
Ok(None) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
Ok(Json(appt.into()))
}
pub async fn update_appointment(
State(state): State<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

@ -27,20 +27,6 @@ pub struct RegisterRequest {
/// Wrapped DEK (base64 ciphertext) — encrypted under the recovery KEK
pub recovery_wrapped_dek: Option<String>,
pub recovery_wrapped_dek_iv: Option<String>,
/// Account X25519 identity public key (base64 raw). Plaintext.
pub identity_public_key: Option<String>,
/// Account X25519 identity private key, wrapped under the account DEK.
pub identity_private_key_wrapped: Option<String>,
pub identity_private_key_wrapped_iv: Option<String>,
/// Default-profile display name (opaque, encrypted under the profile DEK)
/// and the profile DEK wrapped under the account DEK. When present, the
/// register flow auto-creates a "self" profile; when absent, no profile is
/// created and the client is expected to POST /api/profiles on first run.
pub default_profile_name_data: Option<String>,
pub default_profile_name_iv: Option<String>,
pub default_profile_name_auth_tag: Option<String>,
pub default_wrapped_profile_dek: Option<String>,
pub default_wrapped_profile_dek_iv: Option<String>,
}
#[derive(Debug, Serialize)]
@ -54,12 +40,6 @@ pub struct AuthResponse {
/// user has no wrapped DEK (legacy/pre-recovery accounts).
pub wrapped_dek: Option<String>,
pub wrapped_dek_iv: Option<String>,
/// Account X25519 identity keypair. The public key is plaintext; the
/// private key is an opaque ciphertext blob wrapped under the account DEK.
/// Absent for accounts that predate the identity-keypair feature.
pub identity_public_key: Option<String>,
pub identity_private_key_wrapped: Option<String>,
pub identity_private_key_wrapped_iv: Option<String>,
}
pub async fn register(
@ -128,11 +108,6 @@ pub async fn register(
user.recovery_wrapped_dek = req.recovery_wrapped_dek.clone();
user.recovery_wrapped_dek_iv = req.recovery_wrapped_dek_iv.clone();
user.recovery_enabled = req.recovery_wrapped_dek.is_some();
// Store the X25519 identity keypair (public plaintext, private wrapped
// under the account DEK — server stores verbatim, cannot decrypt).
user.identity_public_key = req.identity_public_key;
user.identity_private_key_wrapped = req.identity_private_key_wrapped;
user.identity_private_key_wrapped_iv = req.identity_private_key_wrapped_iv;
// Get token_version before saving
let token_version = user.token_version;
@ -154,29 +129,17 @@ pub async fn register(
.await;
}
// Auto-create the default "self" profile for the new user, but only
// when the client provided a wrapped profile DEK. The profile_id is
// deterministic (profile_<owner>). Best-effort: registration still
// succeeds if this fails or if no wrapped DEK was provided (the
// client creates the profile via POST /api/profiles on first run).
if let (Some(wrapped_dek), Some(wrapped_dek_iv)) = (
req.default_wrapped_profile_dek.as_ref(),
req.default_wrapped_profile_dek_iv.as_ref(),
) {
let database = state.db.get_database();
let profile_repo =
crate::models::profile::ProfileRepository::new(database.collection("profiles"));
let profile = crate::handlers::profile::build_default_profile(
&id.to_string(),
wrapped_dek,
wrapped_dek_iv,
req.default_profile_name_data.as_deref().unwrap_or(""),
req.default_profile_name_iv.as_deref().unwrap_or(""),
req.default_profile_name_auth_tag.as_deref().unwrap_or(""),
);
if let Err(e) = profile_repo.create(&profile).await {
tracing::warn!("Failed to auto-create profile for {}: {}", id, e);
}
// Auto-create a default profile for the new user. The profile_id is
// deterministic (profile_<user_id>) and is the contract the frontend
// uses for medication creation. Best-effort: registration still
// succeeds if this fails (GET /profiles/me lazily creates one).
let database = state.db.get_database();
let profile_repo =
crate::models::profile::ProfileRepository::new(database.collection("profiles"));
let profile =
crate::handlers::profile::build_default_profile(&id.to_string(), &req.username);
if let Err(e) = profile_repo.create(&profile).await {
tracing::warn!("Failed to auto-create profile for {}: {}", id, e);
}
id
@ -240,9 +203,6 @@ pub async fn register(
username: user.username.clone(),
wrapped_dek: user.wrapped_dek.clone(),
wrapped_dek_iv: user.wrapped_dek_iv.clone(),
identity_public_key: user.identity_public_key.clone(),
identity_private_key_wrapped: user.identity_private_key_wrapped.clone(),
identity_private_key_wrapped_iv: user.identity_private_key_wrapped_iv.clone(),
};
(StatusCode::CREATED, Json(response)).into_response()
@ -453,9 +413,6 @@ pub async fn login(
username: user.username.clone(),
wrapped_dek: user.wrapped_dek.clone(),
wrapped_dek_iv: user.wrapped_dek_iv.clone(),
identity_public_key: user.identity_public_key.clone(),
identity_private_key_wrapped: user.identity_private_key_wrapped.clone(),
identity_private_key_wrapped_iv: user.identity_private_key_wrapped_iv.clone(),
};
(StatusCode::OK, Json(response)).into_response()

View file

@ -3,7 +3,7 @@ use crate::config::AppState;
use crate::models::health_stats::{HealthStatResponse, HealthStatistic};
use crate::models::medication::EncryptedFieldWire;
use axum::{
extract::{Path, Query, State},
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Extension, Json,
@ -14,16 +14,9 @@ use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct CreateHealthStatRequest {
pub encrypted_data: EncryptedFieldWire,
/// Which profile this stat belongs to.
pub profile_id: String,
pub recorded_at: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
pub struct ListHealthStatsQuery {
pub profile_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateHealthStatRequest {
pub encrypted_data: EncryptedFieldWire,
@ -48,7 +41,6 @@ pub async fn create_health_stat(
let stat = HealthStatistic {
id: None,
user_id: claims.sub.clone(),
profile_id: req.profile_id,
encrypted_data: req.encrypted_data,
recorded_at: req
.recorded_at
@ -78,7 +70,6 @@ pub async fn create_health_stat(
pub async fn list_health_stats(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Query(query): Query<ListHealthStatsQuery>,
) -> impl IntoResponse {
let repo = match state.health_stats_repo.as_ref() {
Some(r) => r,
@ -90,34 +81,7 @@ pub async fn list_health_stats(
.into_response()
}
};
// Resolve the effective owner. If a profile_id is specified, prefer direct
// ownership (the caller's own data for that profile); otherwise fall through
// to the share-gate (admits an active share recipient). profile_id is a
// free-form string at create time and may not map to a Profile document.
let owner = match &query.profile_id {
Some(pid) => {
let has_own = repo
.find_by_user_filtered(&claims.sub, Some(pid))
.await
.map(|v| !v.is_empty())
.unwrap_or(false);
if has_own {
claims.sub.clone()
} else {
match crate::handlers::profile_share::authorize_profile_read(&state, &claims, pid)
.await
{
Ok(o) => o,
Err(resp) => return resp.into_response(),
}
}
}
None => claims.sub.clone(),
};
match repo
.find_by_user_filtered(&owner, query.profile_id.as_deref())
.await
{
match repo.find_by_user(&claims.sub).await {
Ok(stats) => {
let resp: Vec<HealthStatResponse> = stats.into_iter().map(Into::into).collect();
(StatusCode::OK, Json(resp)).into_response()
@ -158,21 +122,10 @@ pub async fn get_health_stat(
match repo.find_by_id(&object_id).await {
Ok(Some(stat)) => {
// Authorization: direct owner (user_id == caller) OR share-gate on
// the stat's profile (admits an active share recipient).
if stat.user_id == claims.sub {
return (StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response();
}
match crate::handlers::profile_share::authorize_profile_read(
&state,
&claims,
&stat.profile_id,
)
.await
{
Ok(_) => (StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response(),
Err(resp) => resp.into_response(),
if stat.user_id != claims.sub {
return (StatusCode::FORBIDDEN, "Access denied").into_response();
}
(StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,

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" })),
));
}
Err(e) => {
tracing::error!("get_medication failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
// Authorization: admit the direct owner (user_id == caller) OR, if not,
// fall through to the share-gate on the medication's profile (admits an
// active share recipient). The direct-owner check preserves the original
// ownership model for medications whose profile_id may not map to a real
// Profile document (e.g. legacy/test data).
if medication.user_id != claims.sub {
crate::handlers::profile_share::authorize_profile_read(
&state,
&claims,
&medication.profile_id,
)
.await?;
match repo.find_by_medication_id(&id).await {
Ok(Some(medication)) => Ok(Json(medication.into())),
Ok(None) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
Ok(Json(medication.into()))
}
pub async fn update_medication(
State(state): State<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 profile::{create_profile, delete_profile, get_profile, list_profiles, update_profile};
pub use profile_share::{
create_share, delete_share, get_profile_shared_aware, get_public_key, list_shared_with_me,
list_shares, rekey_profile,
};
pub use permissions::check_permission;
pub use profile::{get_my_profile, update_my_profile};
pub use sessions::{get_sessions, revoke_all_sessions, revoke_session};
pub use shares::{create_share, delete_share, list_shares, update_share};
pub use users::{
change_password, delete_account, get_account, get_settings, update_account, update_settings,
change_password, delete_account, get_profile, get_settings, update_profile, update_settings,
};

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,5 +1,5 @@
use axum::{
extract::{Extension, Path, State},
extract::{Extension, State},
http::StatusCode,
Json,
};
@ -12,21 +12,17 @@ use crate::{
models::profile::{Profile, ProfileRepository},
};
/// The profile as exposed to clients. Display name + wrapped profile DEK are
/// returned as opaque client-encrypted blobs (the server cannot read either).
/// The profile as exposed to clients. The display name is returned as an opaque
/// client-encrypted blob (the server cannot read it).
#[derive(Debug, Serialize)]
pub struct ProfileResponse {
pub profile_id: String,
pub owner_account_id: String,
pub user_id: String,
/// Opaque client-encrypted display name.
pub name_data: String,
pub name_iv: String,
pub kind: String,
pub relationship: String,
pub role: String,
pub permissions: Vec<String>,
/// Profile DEK wrapped under the owner's account DEK (opaque).
pub wrapped_profile_dek: String,
pub wrapped_profile_dek_iv: String,
pub created_at: DateTime,
pub updated_at: DateTime,
}
@ -35,212 +31,98 @@ impl From<Profile> for ProfileResponse {
fn from(p: Profile) -> Self {
Self {
profile_id: p.profile_id,
owner_account_id: p.owner_account_id,
user_id: p.user_id,
name_data: p.name,
name_iv: p.name_iv,
kind: p.kind,
relationship: p.relationship,
role: p.role,
permissions: p.permissions,
wrapped_profile_dek: p.wrapped_profile_dek,
wrapped_profile_dek_iv: p.wrapped_profile_dek_iv,
created_at: p.created_at,
updated_at: p.updated_at,
}
}
}
/// Build a default "self" profile for a freshly registered user. Called from
/// `register` when the client provides a wrapped profile DEK for the default
/// profile. The server has no encryption key, so the name blob and wrapped
/// profile DEK are taken verbatim from the caller (the client generated the
/// profile DEK and wrapped it under the account DEK).
pub fn build_default_profile(
owner_account_id: &str,
wrapped_profile_dek: &str,
wrapped_profile_dek_iv: &str,
name_data: &str,
name_iv: &str,
name_auth_tag: &str,
) -> Profile {
/// Create the default "patient" profile for a freshly registered user. Called
/// from `register`. The profile_id is deterministic: `profile_<user_id>` — this
/// is the contract the frontend relies on for medication creation. The name is
/// empty (not encrypted) because the server has no encryption key; the client
/// sets it via PUT /profiles/me after deriving its key.
pub fn build_default_profile(user_id: &str, _name: &str) -> Profile {
let now = DateTime::now();
Profile {
id: None,
// profile_<owner> keeps the deterministic-id contract the frontend
// used pre-A2 for the self profile.
profile_id: format!("profile_{owner_account_id}"),
owner_account_id: owner_account_id.to_string(),
user_id: owner_account_id.to_string(),
profile_id: format!("profile_{user_id}"),
user_id: user_id.to_string(),
family_id: None,
name: name_data.to_string(),
name_iv: name_iv.to_string(),
name_auth_tag: name_auth_tag.to_string(),
kind: "human".to_string(),
relationship: "self".to_string(),
name: String::new(),
name_iv: String::new(),
name_auth_tag: String::new(),
role: "patient".to_string(),
permissions: vec!["read:self".to_string(), "write:self".to_string()],
wrapped_profile_dek: wrapped_profile_dek.to_string(),
wrapped_profile_dek_iv: wrapped_profile_dek_iv.to_string(),
created_at: now,
updated_at: now,
}
}
fn profile_repo(state: &AppState) -> ProfileRepository {
ProfileRepository::new(state.db.get_database().collection("profiles"))
}
// ---------------------------------------------------------------------------
// GET /api/profiles — list all profiles owned by the current account
// ---------------------------------------------------------------------------
pub async fn list_profiles(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
) -> Result<Json<Vec<ProfileResponse>>, (StatusCode, Json<serde_json::Value>)> {
let repo = profile_repo(&state);
match repo.find_all_by_owner(&claims.sub).await {
Ok(profiles) => {
let resp: Vec<ProfileResponse> = profiles.into_iter().map(Into::into).collect();
Ok(Json(resp))
}
Err(e) => {
tracing::error!("Profile list failed: {}", e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
))
}
}
}
// ---------------------------------------------------------------------------
// POST /api/profiles — create a new profile (child, pet, ...).
// The client generates the profile DEK, wraps it under the account DEK, and
// sends the wrapped blob + the encrypted display name.
// ---------------------------------------------------------------------------
/// PUT /profiles/me body — the client sends the encrypted name blob.
#[derive(Debug, Deserialize)]
pub struct CreateProfileRequest {
/// Optional explicit profile id; defaults to a fresh UUID if absent.
#[serde(default)]
pub profile_id: Option<String>,
#[serde(default = "default_kind_value")]
pub kind: String,
#[serde(default)]
pub relationship: String,
pub struct UpdateProfileNameRequest {
pub name_data: String,
pub name_iv: String,
#[serde(default)]
pub name_auth_tag: String,
/// Profile DEK wrapped under the account DEK (opaque ciphertext).
pub wrapped_profile_dek: String,
pub wrapped_profile_dek_iv: String,
}
fn default_kind_value() -> String {
"human".to_string()
}
pub async fn create_profile(
/// GET /api/profiles/me — the current user's profile. If for some reason the
/// auto-created profile is missing, lazily create it.
pub async fn get_my_profile(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Json(req): Json<CreateProfileRequest>,
) -> Result<(StatusCode, Json<ProfileResponse>), (StatusCode, Json<serde_json::Value>)> {
let now = DateTime::now();
let profile = Profile {
id: None,
profile_id: req
.profile_id
.unwrap_or_else(|| format!("p_{}", uuid::Uuid::new_v4())),
owner_account_id: claims.sub.clone(),
user_id: claims.sub.clone(),
family_id: None,
name: req.name_data,
name_iv: req.name_iv,
name_auth_tag: req.name_auth_tag,
kind: req.kind,
relationship: req.relationship,
role: "patient".to_string(),
permissions: vec!["read:self".to_string(), "write:self".to_string()],
wrapped_profile_dek: req.wrapped_profile_dek,
wrapped_profile_dek_iv: req.wrapped_profile_dek_iv,
created_at: now,
updated_at: now,
};
let repo = profile_repo(&state);
if let Err(e) = repo.create(&profile).await {
tracing::error!("Profile create failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
Ok((StatusCode::CREATED, Json(ProfileResponse::from(profile))))
}
// ---------------------------------------------------------------------------
// GET /api/profiles/:id — fetch one owned profile
// ---------------------------------------------------------------------------
pub async fn get_profile(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Path(profile_id): Path<String>,
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
let repo = profile_repo(&state);
match repo
.find_by_profile_id_owned(&profile_id, &claims.sub)
.await
{
Ok(Some(p)) => Ok(Json(ProfileResponse::from(p))),
Ok(None) => Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "profile not found" })),
)),
let database = state.db.get_database();
let repo = ProfileRepository::new(database.collection("profiles"));
let profile = match repo.find_by_user_id(&claims.sub).await {
Ok(Some(p)) => p,
Ok(None) => {
// Lazily create if missing (e.g. users registered before this code shipped).
let p = build_default_profile(&claims.sub, &claims.sub);
if let Err(e) = repo.create(&p).await {
tracing::error!("Failed to lazily create profile: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "failed to load profile" })),
));
}
p
}
Err(e) => {
tracing::error!("Profile lookup failed: {}", e);
Err((
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
))
));
}
}
};
Ok(Json(ProfileResponse::from(profile)))
}
// ---------------------------------------------------------------------------
// PUT /api/profiles/:id — update an owned profile's mutable fields
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct UpdateProfileRequest {
pub name_data: String,
pub name_iv: String,
#[serde(default)]
pub name_auth_tag: String,
#[serde(default = "default_kind_value")]
pub kind: String,
#[serde(default)]
pub relationship: String,
}
pub async fn update_profile(
/// PUT /api/profiles/me — update the profile's encrypted display-name blob.
pub async fn update_my_profile(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Path(profile_id): Path<String>,
Json(req): Json<UpdateProfileRequest>,
Json(req): Json<UpdateProfileNameRequest>,
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
let repo = profile_repo(&state);
let database = state.db.get_database();
let repo = ProfileRepository::new(database.collection("profiles"));
match repo
.update_profile(
&profile_id,
.update_encrypted_name(
&claims.sub,
&req.name_data,
&req.name_iv,
&req.name_auth_tag,
&req.kind,
&req.relationship,
)
.await
{
@ -258,29 +140,3 @@ pub async fn update_profile(
}
}
}
// ---------------------------------------------------------------------------
// DELETE /api/profiles/:id — delete an owned profile
// ---------------------------------------------------------------------------
pub async fn delete_profile(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Path(profile_id): Path<String>,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
let repo = profile_repo(&state);
match repo.delete_profile(&profile_id, &claims.sub).await {
Ok(true) => Ok(StatusCode::NO_CONTENT),
Ok(false) => Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "profile not found" })),
)),
Err(e) => {
tracing::error!("Profile delete failed: {}", e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
))
}
}
}

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

@ -39,7 +39,7 @@ pub struct UpdateProfileRequest {
pub username: Option<String>,
}
pub async fn get_account(
pub async fn get_profile(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
) -> impl IntoResponse {
@ -79,7 +79,7 @@ pub async fn get_account(
}
}
pub async fn update_account(
pub async fn update_profile(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Json(req): Json<UpdateProfileRequest>,

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

@ -126,21 +126,16 @@ impl AppointmentRepository {
Ok(appointment)
}
/// List a user's appointments, optionally filtered by top-level `status`
/// and/or `profileId`.
/// List a user's appointments, optionally filtered by top-level `status`.
pub async fn find_by_user_filtered(
&self,
user_id: &str,
status: Option<&str>,
profile_id: Option<&str>,
) -> Result<Vec<Appointment>, Box<dyn std::error::Error>> {
let mut filter = doc! { "userId": user_id };
if let Some(s) = status {
filter.insert("status", s);
}
if let Some(p) = profile_id {
filter.insert("profileId", p);
}
let mut cursor = self.collection.find(filter, None).await?;
let mut appointments = Vec::new();
while let Some(appt) = cursor.next().await {

View file

@ -15,9 +15,6 @@ pub struct HealthStatistic {
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
pub id: Option<ObjectId>,
pub user_id: String,
/// Which profile this stat belongs to (plaintext, filterable).
#[serde(rename = "profileId", default)]
pub profile_id: String,
/// Opaque client-encrypted blob (value, unit, type, notes inside).
#[serde(rename = "encryptedData")]
pub encrypted_data: EncryptedFieldWire,
@ -30,7 +27,6 @@ pub struct HealthStatistic {
pub struct HealthStatResponse {
pub id: String,
pub user_id: String,
pub profile_id: String,
pub encrypted_data: EncryptedFieldWire,
pub recorded_at: String,
}
@ -40,7 +36,6 @@ impl From<HealthStatistic> for HealthStatResponse {
HealthStatResponse {
id: s.id.map(|o| o.to_hex()).unwrap_or_default(),
user_id: s.user_id,
profile_id: s.profile_id,
encrypted_data: s.encrypted_data,
recorded_at: s.recorded_at,
}
@ -75,21 +70,6 @@ impl HealthStatisticsRepository {
cursor.try_collect().await
}
/// All stats for a user, optionally filtered by profile_id at the Mongo
/// level (real top-level document field).
pub async fn find_by_user_filtered(
&self,
user_id: &str,
profile_id: Option<&str>,
) -> Result<Vec<HealthStatistic>, MongoError> {
let mut filter = doc! { "user_id": user_id };
if let Some(pid) = profile_id {
filter.insert("profileId", pid);
}
let cursor = self.collection.find(filter, None).await?;
cursor.try_collect().await
}
pub async fn find_by_id(&self, id: &ObjectId) -> Result<Option<HealthStatistic>, MongoError> {
let filter = doc! { "_id": id };
self.collection.find_one(filter, None).await

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

@ -1,76 +1,35 @@
use futures::stream::StreamExt;
use mongodb::{
bson::{doc, oid::ObjectId, DateTime},
Collection,
};
use serde::{Deserialize, Serialize};
/// A profile (a "subject of care" — a person or pet whose health data is
/// tracked). One account owns zero or more profiles. All health data
/// (medications, appointments, health stats) is scoped to a profile and
/// encrypted under that profile's DEK.
///
/// Zero-knowledge layers (both opaque to the server):
/// - `name` / `wrapped_profile_dek` are AES-256-GCM ciphertext blobs the
/// server stores verbatim and cannot read.
/// - `name` is the profile's display name encrypted under the profile DEK.
/// - `wrapped_profile_dek` is the profile DEK encrypted under the owner's
/// account DEK. The client unwraps it at login/unlock (after unwrapping
/// the account DEK) to get the profile DEK.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
pub id: Option<ObjectId>,
#[serde(rename = "profileId")]
pub profile_id: String,
/// Owner account id (Mongo ObjectId hex). Replaces the 1:1 `user_id`
/// semantics from the pre-A2 single-profile model.
#[serde(rename = "ownerAccountId")]
pub owner_account_id: String,
/// Kept populated = owner_account_id for backward compat with existing
/// medication/appointment filters that key on `userId`. Same value; do
/// not rely on it for ownership — use `owner_account_id`.
#[serde(rename = "userId")]
pub user_id: String,
#[serde(rename = "familyId")]
pub family_id: Option<String>,
/// Display name — opaque, encrypted under the profile DEK.
#[serde(rename = "name")]
pub name: String,
#[serde(rename = "nameIv")]
pub name_iv: String,
#[serde(rename = "nameAuthTag")]
pub name_auth_tag: String,
/// "human" | "pet" — what kind of subject this profile tracks.
#[serde(rename = "kind", default = "default_kind")]
pub kind: String,
/// Freeform relationship to the owner: "self", "child", "spouse",
/// "parent", "pet", ...
#[serde(rename = "relationship", default)]
pub relationship: String,
#[serde(rename = "role", default = "default_role")]
#[serde(rename = "role")]
pub role: String,
#[serde(rename = "permissions", default)]
#[serde(rename = "permissions")]
pub permissions: Vec<String>,
/// Profile DEK wrapped under the owner's account DEK (opaque ciphertext).
#[serde(rename = "wrappedProfileDek")]
pub wrapped_profile_dek: String,
#[serde(rename = "wrappedProfileDekIv")]
pub wrapped_profile_dek_iv: String,
#[serde(rename = "createdAt")]
pub created_at: DateTime,
#[serde(rename = "updatedAt")]
pub updated_at: DateTime,
}
fn default_kind() -> String {
"human".to_string()
}
fn default_role() -> String {
"patient".to_string()
}
pub struct ProfileRepository {
collection: Collection<Profile>,
}
@ -85,7 +44,6 @@ impl ProfileRepository {
Ok(())
}
/// Look up a profile by its application-level profile id.
pub async fn find_by_profile_id(
&self,
profile_id: &str,
@ -95,102 +53,32 @@ impl ProfileRepository {
.await
}
/// Look up a profile by id AND owner — used for ownership-scoped access.
/// Returns None if the profile doesn't exist or doesn't belong to `owner`.
pub async fn find_by_profile_id_owned(
&self,
profile_id: &str,
owner: &str,
) -> mongodb::error::Result<Option<Profile>> {
/// Look up a profile by its owning user id.
pub async fn find_by_user_id(&self, user_id: &str) -> mongodb::error::Result<Option<Profile>> {
self.collection
.find_one(
doc! { "profileId": profile_id, "ownerAccountId": owner },
None,
)
.find_one(doc! { "userId": user_id }, None)
.await
}
/// All profiles owned by an account.
pub async fn find_all_by_owner(&self, owner: &str) -> mongodb::error::Result<Vec<Profile>> {
let mut cursor = self
.collection
.find(doc! { "ownerAccountId": owner }, None)
.await?;
let mut out = Vec::new();
while let Some(p) = cursor.next().await {
out.push(p?);
}
Ok(out)
}
/// Replace a profile's mutable fields (display name blob, kind,
/// relationship), keyed by (profile_id, owner). Returns the updated doc
/// or None if the profile doesn't exist / isn't owned by `owner`.
pub async fn update_profile(
/// Update the profile's display name (opaque client-encrypted blob).
pub async fn update_encrypted_name(
&self,
profile_id: &str,
owner: &str,
user_id: &str,
name_data: &str,
name_iv: &str,
name_auth_tag: &str,
kind: &str,
relationship: &str,
) -> mongodb::error::Result<Option<Profile>> {
self.collection
.find_one_and_update(
doc! { "profileId": profile_id, "ownerAccountId": owner },
doc! { "userId": user_id },
doc! { "$set": {
"name": name_data,
"nameIv": name_iv,
"nameAuthTag": name_auth_tag,
"kind": kind,
"relationship": relationship,
"updatedAt": DateTime::now()
}},
None,
)
.await
}
/// Rotate the profile's wrapped DEK (owner share) — Phase C hard revoke.
/// Stores the new account-wrapped profile DEK verbatim, keyed by
/// (profile_id, owner). Returns the updated profile or None if it doesn't
/// exist / isn't owned by `owner`. The server stores the opaque blob
/// verbatim and cannot decrypt it.
pub async fn update_wrapped_dek(
&self,
profile_id: &str,
owner: &str,
new_wrapped_profile_dek: &str,
new_wrapped_profile_dek_iv: &str,
) -> mongodb::error::Result<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(
&self,
profile_id: &str,
owner: &str,
) -> mongodb::error::Result<bool> {
let res = self
.collection
.delete_one(
doc! { "profileId": profile_id, "ownerAccountId": owner },
None,
)
.await?;
Ok(res.deleted_count > 0)
}
}

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

@ -37,21 +37,6 @@ pub struct User {
#[serde(skip_serializing_if = "Option::is_none")]
pub recovery_wrapped_dek_iv: Option<String>,
/// Account X25519 identity public key (base64 raw). Plaintext — public keys
/// are not secret. Other accounts wrap profile DEKs to this key (Phase B,
/// see docs/adr/multi-person-sharing.md). Generated client-side at
/// registration.
#[serde(skip_serializing_if = "Option::is_none")]
pub identity_public_key: Option<String>,
/// Account X25519 identity private key, wrapped (AES-256-GCM encrypted)
/// under the account DEK. The server stores this verbatim and cannot
/// decrypt it. The client unwraps it at login after unwrapping the DEK.
#[serde(skip_serializing_if = "Option::is_none")]
pub identity_private_key_wrapped: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub identity_private_key_wrapped_iv: Option<String>,
/// Token version for invalidating all tokens on password change
pub token_version: i32,
@ -108,9 +93,6 @@ impl User {
wrapped_dek_iv: None,
recovery_wrapped_dek: None,
recovery_wrapped_dek_iv: None,
identity_public_key: None,
identity_private_key_wrapped: None,
identity_private_key_wrapped_iv: None,
token_version: 0,
created_at: now,
last_active: now,

View file

@ -287,80 +287,6 @@ async fn password_change_invalidates_existing_tokens() {
common::drop_test_db(&db_name).await;
}
#[tokio::test]
async fn identity_keypair_round_trips_through_register_and_login() {
// Phase A1 (#3): the X25519 identity keypair fields (public plaintext,
// private wrapped under the account DEK) are stored verbatim at register
// and echoed back on login. The server never inspects or derives them.
let (app, db_name) = require_app!(common::app_for_test().await);
let email = unique_email();
// Opaque blobs — the server treats these as base64 strings, nothing more.
let public_key = "base64-x25519-public-key-for-test";
let priv_wrapped = "base64-wrapped-priv-ciphertext";
let priv_wrapped_iv = "base64-12-byte-iv";
let (status, body) = common::send_json(
&app,
"POST",
"/api/auth/register",
Some(json!({
"email": email,
"username": "tester",
"password": "supersecret",
"identity_public_key": public_key,
"identity_private_key_wrapped": priv_wrapped,
"identity_private_key_wrapped_iv": priv_wrapped_iv,
})),
None,
)
.await;
assert_eq!(status, 201, "register should return 201, body: {body}");
assert_eq!(body["identity_public_key"], public_key);
assert_eq!(body["identity_private_key_wrapped"], priv_wrapped);
assert_eq!(body["identity_private_key_wrapped_iv"], priv_wrapped_iv);
// Login must echo the same stored values.
let (status, body) = common::send_json(
&app,
"POST",
"/api/auth/login",
Some(json!({ "email": email, "password": "supersecret" })),
None,
)
.await;
assert_eq!(status, 200, "login should return 200, body: {body}");
assert_eq!(body["identity_public_key"], public_key);
assert_eq!(body["identity_private_key_wrapped"], priv_wrapped);
assert_eq!(body["identity_private_key_wrapped_iv"], priv_wrapped_iv);
common::drop_test_db(&db_name).await;
}
#[tokio::test]
async fn register_without_identity_keypair_stays_optional() {
// Backward compat: existing clients that don't send identity fields must
// still register successfully, and the fields are absent from the response.
let (app, db_name) = require_app!(common::app_for_test().await);
let email = unique_email();
let (status, body) = common::send_json(
&app,
"POST",
"/api/auth/register",
Some(json!({ "email": email, "username": "tester", "password": "supersecret" })),
None,
)
.await;
assert_eq!(status, 201, "register should return 201, body: {body}");
assert!(
body.get("identity_public_key").is_none() || body["identity_public_key"].is_null(),
"identity_public_key should be absent when not provided: {body}"
);
common::drop_test_db(&db_name).await;
}
// ---- helpers ----
fn unique_email() -> String {

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,255 +0,0 @@
//! Multi-profile integration tests (Phase A2).
//!
//! Exercises the /api/profiles CRUD endpoints: list, create, get, update,
//! delete — all ownership-scoped. A second registered user must not be able
//! to read or modify the first user's profiles.
//!
//! Requires a live MongoDB; skips gracefully otherwise (see common/mod.rs).
mod common;
use serde_json::{json, Value};
macro_rules! require_app {
($app:expr) => {
match $app {
Some(x) => x,
None => {
eprintln!("[integration] skipped (MongoDB unavailable)");
return;
}
}
};
}
/// Register a user (sending no default-profile fields → no profile auto-created)
/// and return the access token.
async fn register(app: &axum::Router, email: &str, password: &str) -> String {
let (status, body) = common::send_json(
app,
"POST",
"/api/auth/register",
Some(json!({ "email": email, "username": "tester", "password": password })),
None,
)
.await;
assert_eq!(status, 201, "register precondition failed, body: {body}");
body["token"].as_str().unwrap().to_string()
}
fn unique_email() -> String {
format!("test_{}@example.com", uuid::Uuid::new_v4())
}
/// Body for creating a profile with a fake wrapped DEK (the server stores it
/// verbatim; it never inspects the contents).
fn create_body(kind: &str, relationship: &str) -> Value {
json!({
"kind": kind,
"relationship": relationship,
"name_data": "base64-encrypted-name",
"name_iv": "base64-iv",
"wrapped_profile_dek": "base64-wrapped-profile-dek",
"wrapped_profile_dek_iv": "base64-dek-iv",
})
}
#[tokio::test]
async fn list_starts_empty_without_default_profile() {
let (app, db_name) = require_app!(common::app_for_test().await);
let token = register(&app, &unique_email(), "supersecret").await;
let (status, body) = common::send_json(&app, "GET", "/api/profiles", None, Some(&token)).await;
assert_eq!(status, 200, "list should return 200, body: {body}");
assert_eq!(
body.as_array().unwrap().len(),
0,
"no default profile expected"
);
common::drop_test_db(&db_name).await;
}
#[tokio::test]
async fn create_then_list_then_get_update_delete() {
let (app, db_name) = require_app!(common::app_for_test().await);
let token = register(&app, &unique_email(), "supersecret").await;
// Create a profile.
let (status, body) = common::send_json(
&app,
"POST",
"/api/profiles",
Some(create_body("human", "self")),
Some(&token),
)
.await;
assert_eq!(status, 201, "create should return 201, body: {body}");
let profile_id = body["profile_id"].as_str().unwrap().to_string();
assert_eq!(body["kind"], "human");
assert_eq!(body["relationship"], "self");
assert_eq!(body["wrapped_profile_dek"], "base64-wrapped-profile-dek");
// List now has one.
let (_, list_body) = common::send_json(&app, "GET", "/api/profiles", None, Some(&token)).await;
assert_eq!(list_body.as_array().unwrap().len(), 1);
// Get by id.
let (status, get_body) = common::send_json(
&app,
"GET",
&format!("/api/profiles/{profile_id}"),
None,
Some(&token),
)
.await;
assert_eq!(status, 200);
assert_eq!(get_body["profile_id"], profile_id);
// Update the display-name blob + relationship.
let (status, _) = common::send_json(
&app,
"PUT",
&format!("/api/profiles/{profile_id}"),
Some(json!({
"name_data": "new-name-blob",
"name_iv": "new-iv",
"kind": "pet",
"relationship": "dog",
})),
Some(&token),
)
.await;
assert_eq!(status, 200, "update should return 200");
// Delete.
let (status, _) = common::send_json(
&app,
"DELETE",
&format!("/api/profiles/{profile_id}"),
None,
Some(&token),
)
.await;
assert_eq!(status, 204, "delete should return 204");
// Get now 404s.
let (status, _) = common::send_json(
&app,
"GET",
&format!("/api/profiles/{profile_id}"),
None,
Some(&token),
)
.await;
assert_eq!(status, 404);
common::drop_test_db(&db_name).await;
}
#[tokio::test]
async fn other_user_cannot_access_profile() {
// Ownership isolation: user B must not GET/UPDATE/DELETE user A's profile.
let (app, db_name) = require_app!(common::app_for_test().await);
let token_a = register(&app, &unique_email(), "supersecret").await;
let token_b = register(&app, &unique_email(), "supersecret").await;
// A creates a profile.
let (_, body) = common::send_json(
&app,
"POST",
"/api/profiles",
Some(create_body("human", "child")),
Some(&token_a),
)
.await;
let profile_id = body["profile_id"].as_str().unwrap().to_string();
// B cannot GET it (404, because ownership filter excludes it).
let (status, _) = common::send_json(
&app,
"GET",
&format!("/api/profiles/{profile_id}"),
None,
Some(&token_b),
)
.await;
assert_eq!(status, 404, "B must not read A's profile");
// B cannot UPDATE it.
let (status, _) = common::send_json(
&app,
"PUT",
&format!("/api/profiles/{profile_id}"),
Some(json!({
"name_data": "x", "name_iv": "y", "kind": "human", "relationship": "self"
})),
Some(&token_b),
)
.await;
assert_eq!(status, 404, "B must not update A's profile");
// B cannot DELETE it.
let (status, _) = common::send_json(
&app,
"DELETE",
&format!("/api/profiles/{profile_id}"),
None,
Some(&token_b),
)
.await;
assert_eq!(status, 404, "B must not delete A's profile");
// A still sees it intact.
let (status, _) = common::send_json(
&app,
"GET",
&format!("/api/profiles/{profile_id}"),
None,
Some(&token_a),
)
.await;
assert_eq!(status, 200, "A's profile must be untouched");
common::drop_test_db(&db_name).await;
}
#[tokio::test]
async fn register_with_default_profile_creates_self_profile() {
// When register carries default_wrapped_profile_dek, the self profile is
// auto-created and shows up in GET /api/profiles.
let (app, db_name) = require_app!(common::app_for_test().await);
let email = unique_email();
let (_, body) = common::send_json(
&app,
"POST",
"/api/auth/register",
Some(json!({
"email": email,
"username": "tester",
"password": "supersecret",
"default_profile_name_data": "name-blob",
"default_profile_name_iv": "name-iv",
"default_wrapped_profile_dek": "dek-blob",
"default_wrapped_profile_dek_iv": "dek-iv",
})),
None,
)
.await;
let token = body["token"].as_str().unwrap().to_string();
let (_, list_body) = common::send_json(&app, "GET", "/api/profiles", None, Some(&token)).await;
let arr = list_body.as_array().unwrap();
assert_eq!(arr.len(), 1, "default self profile should exist");
assert_eq!(arr[0]["relationship"], "self");
assert_eq!(arr[0]["kind"], "human");
assert_eq!(arr[0]["wrapped_profile_dek"], "dek-blob");
// Deterministic id contract: profile_<owner>.
assert!(arr[0]["profile_id"]
.as_str()
.unwrap()
.starts_with("profile_"));
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

@ -179,7 +179,6 @@ async fn health_stat_stored_as_ciphertext() {
"POST",
"/api/health-stats",
Some(json!({
"profile_id": "default",
"encrypted_data": { "data": opaque_data, "iv": "aXY=" },
"recorded_at": "2026-07-01T10:00:00Z",
})),

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

@ -24,7 +24,7 @@ import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import { format } from 'date-fns';
import { useAppointmentStore, useProfileStore } from '../../store/useStore';
import { useAppointmentStore, useAuthStore } from '../../store/useStore';
import type { Appointment } from '../../types/api';
const APPT_TYPES = ['in-person', 'telehealth', 'lab', 'test', 'other'];
@ -74,7 +74,7 @@ export const AppointmentsManager: FC = () => {
deleteAppointment,
clearError,
} = useAppointmentStore();
const activeProfileId = useProfileStore((s) => s.activeProfileId);
const user = useAuthStore((s) => s.user);
const [createOpen, setCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState<ApptFormData>(emptyCreate);
@ -84,13 +84,13 @@ export const AppointmentsManager: FC = () => {
const [saving, setSaving] = useState(false);
useEffect(() => {
if (activeProfileId) loadAppointments();
}, [loadAppointments, activeProfileId]);
loadAppointments();
}, [loadAppointments]);
const openCreate = () => {
setCreateForm({
...emptyCreate,
profile_id: activeProfileId ?? 'default',
profile_id: `profile_${user?.user_id ?? 'default'}`,
});
setCreateOpen(true);
};

View file

@ -38,8 +38,6 @@ describe('MedicationManager', () => {
resetMockStore();
setMockStore({
useAuthStore: { user: { user_id: 'u1', username: 'tester' }, profile: {} },
// The load effect is gated on activeProfileId (Phase A2); provide one.
useProfileStore: { activeProfileId: 'profile_u1' },
});
baseActions.loadMedications.mockClear();
});

View file

@ -24,7 +24,7 @@ import {
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import { useMedicationStore, useProfileStore } from '../../store/useStore';
import { useMedicationStore, useAuthStore } from '../../store/useStore';
import type { Medication } from '../../types/api';
import { DoseLogger } from './DoseLogger';
@ -60,7 +60,7 @@ export const MedicationManager: FC = () => {
deleteMedication,
clearError,
} = useMedicationStore();
const activeProfileId = useProfileStore((s) => s.activeProfileId);
const user = useAuthStore((s) => s.user);
const [createOpen, setCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState<MedFormData>(emptyCreate);
@ -69,16 +69,17 @@ export const MedicationManager: FC = () => {
const [deleteTarget, setDeleteTarget] = useState<Medication | null>(null);
const [saving, setSaving] = useState(false);
// Reload medications when the active profile changes — each profile's data
// is encrypted under its own DEK and filtered server-side by profile_id.
useEffect(() => {
if (activeProfileId) loadMedications();
}, [loadMedications, activeProfileId]);
loadMedications();
}, [loadMedications]);
const openCreate = () => {
// profile_id is deterministic: profile_<user_id>. The backend auto-creates
// this profile on register, so the id always resolves to a real profile.
const profileId = user?.profile_id ?? `profile_${user?.user_id ?? 'default'}`;
setCreateForm({
...emptyCreate,
profile_id: activeProfileId ?? 'default',
profile_id: profileId,
});
setCreateOpen(true);
};

View file

@ -7,62 +7,34 @@ import {
CircularProgress,
Alert,
Chip,
MenuItem,
Stack,
TextField,
Typography,
} from '@mui/material';
import SaveIcon from '@mui/icons-material/Save';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import { useProfileStore, useAuthStore } from '../../store/useStore';
import { ProfileSharing } from './ProfileSharing';
export const ProfileEditor: FC = () => {
const {
profiles,
activeProfileId,
isLoading,
error,
loadProfiles,
updateProfile,
createProfile,
deleteProfile,
clearError,
} = useProfileStore();
const { profile, isLoading, error, loadProfile, updateName, clearError } =
useProfileStore();
const user = useAuthStore((s) => s.user);
const active = profiles.find((p) => p.profile_id === activeProfileId) ?? null;
const [name, setName] = useState('');
const [kind, setKind] = useState('human');
const [relationship, setRelationship] = useState('');
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
// Create-profile dialog state
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const [newKind, setNewKind] = useState('human');
const [newRel, setNewRel] = useState('');
useEffect(() => {
loadProfile();
}, [loadProfile]);
useEffect(() => {
loadProfiles();
}, [loadProfiles]);
useEffect(() => {
if (active) {
setName(active.name);
setKind(active.kind || 'human');
setRelationship(active.relationship || '');
}
}, [active]);
if (profile?.name) setName(profile.name);
}, [profile]);
const handleSave = async () => {
if (!active) return;
setSaving(true);
try {
await updateProfile(active.profile_id, { name, kind, relationship });
await updateName(name);
setEditing(false);
} catch {
/* error surfaced via store */
@ -71,43 +43,11 @@ export const ProfileEditor: FC = () => {
}
};
const handleCreate = async () => {
setSaving(true);
try {
await createProfile({ name: newName, kind: newKind, relationship: newRel });
setCreating(false);
setNewName('');
setNewKind('human');
setNewRel('');
} catch {
/* error surfaced via store */
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!active) return;
if (profiles.length <= 1) {
alert('You must have at least one profile. Create another before deleting this one.');
return;
}
if (!confirm(`Delete the "${active.name}" profile? Its encrypted data cannot be recovered.`)) return;
try {
await deleteProfile(active.profile_id);
} catch {
/* error surfaced via store */
}
};
return (
<Box>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
<Typography variant="h6">Profiles</Typography>
<Button startIcon={<AddIcon />} onClick={() => setCreating(true)}>
Add profile
</Button>
</Stack>
<Typography variant="h6" sx={{ mb: 2 }}>
Profile
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={clearError}>
@ -115,11 +55,11 @@ export const ProfileEditor: FC = () => {
</Alert>
)}
{isLoading && profiles.length === 0 ? (
{isLoading && !profile ? (
<Box display="flex" justifyContent="center" py={4}>
<CircularProgress />
</Box>
) : active ? (
) : (
<Card>
<CardContent>
<Stack spacing={2}>
@ -144,154 +84,39 @@ export const ProfileEditor: FC = () => {
>
{saving ? <CircularProgress size={24} /> : 'Save'}
</Button>
<Button onClick={() => { setEditing(false); setName(active.name); }}>
<Button onClick={() => { setEditing(false); setName(profile?.name ?? ''); }}>
Cancel
</Button>
</Stack>
) : (
<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>
)}
<Typography variant="h6">{profile?.name ?? user?.username ?? '—'}</Typography>
<Button size="small" onClick={() => setEditing(true)}>Edit</Button>
</Stack>
)}
</Box>
{editing && (
<>
<Box>
<Typography variant="caption" color="text.secondary">Kind</Typography>
<TextField
select
size="small"
fullWidth
value={kind}
onChange={(e) => setKind(e.target.value)}
sx={{ mt: 0.5 }}
>
<MenuItem value="human">Human</MenuItem>
<MenuItem value="pet">Pet</MenuItem>
</TextField>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Relationship
</Typography>
<TextField
size="small"
fullWidth
value={relationship}
onChange={(e) => setRelationship(e.target.value)}
placeholder="self, child, spouse, parent, pet, ..."
sx={{ mt: 0.5 }}
/>
</Box>
</>
)}
<Box>
<Typography variant="caption" color="text.secondary">
Account
</Typography>
<Typography variant="body2">{user?.email}</Typography>
</Box>
{!editing && (
<>
<Box>
<Typography variant="caption" color="text.secondary">Kind / relationship</Typography>
<Box sx={{ mt: 0.5 }}>
<Chip size="small" label={active.kind || 'human'} variant="outlined" sx={{ mr: 1 }} />
{active.relationship && (
<Chip size="small" label={active.relationship} variant="outlined" />
)}
</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>
</Box>
<Typography variant="caption" color="text.secondary">
Profile ID: {active.profile_id}
</Typography>
<Box>
<Button
size="small"
color="error"
startIcon={<DeleteIcon />}
onClick={handleDelete}
>
Delete profile
</Button>
</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>
<Typography variant="caption" color="text.secondary">
Role
</Typography>
<Box sx={{ mt: 0.5 }}>
<Chip size="small" label={profile?.role ?? 'patient'} variant="outlined" />
</Box>
)}
</Stack>
</CardContent>
</Card>
) : (
<Alert severity="info">
No profiles yet. Create one to start tracking health data.
</Alert>
)}
</Box>
{creating && (
<Card sx={{ mt: 2 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>New profile</Typography>
<Stack spacing={2}>
<TextField
label="Display name"
size="small"
fullWidth
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<TextField
select
label="Kind"
size="small"
fullWidth
value={newKind}
onChange={(e) => setNewKind(e.target.value)}
>
<MenuItem value="human">Human</MenuItem>
<MenuItem value="pet">Pet</MenuItem>
</TextField>
<TextField
label="Relationship"
size="small"
fullWidth
value={newRel}
onChange={(e) => setNewRel(e.target.value)}
placeholder="child, spouse, parent, pet, ..."
/>
<Stack direction="row" spacing={1}>
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={handleCreate}
disabled={saving || !newName.trim()}
>
{saving ? <CircularProgress size={24} /> : 'Create'}
</Button>
<Button onClick={() => setCreating(false)}>Cancel</Button>
</Stack>
{profile?.profile_id && (
<Typography variant="caption" color="text.secondary">
Profile ID: {profile.profile_id}
</Typography>
)}
</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,66 +0,0 @@
import { useEffect, type FC } from 'react';
import { Box, Chip, CircularProgress, MenuItem, Select, Typography } from '@mui/material';
import { useProfileStore } from '../../store/useStore';
/** A compact dropdown for switching the active profile (the subject of care
* whose data the dashboard shows). Sits in the AppBar. Triggers `loadProfiles`
* + `loadSharedWithMe` on mount so owned AND shared profiles are ready. */
export const ProfileSwitcher: FC = () => {
const { profiles, activeProfileId, isLoading, loadProfiles, loadSharedWithMe, setActiveProfile } =
useProfileStore();
useEffect(() => {
(async () => {
await loadProfiles();
// Fetch profiles shared TO me after owned profiles are loaded (the
// identity private key must be unwrapped first; loadSharedWithMe is a
// no-op if it isn't available yet).
await loadSharedWithMe();
})();
}, [loadProfiles, loadSharedWithMe]);
if (profiles.length === 0) {
return isLoading ? (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<CircularProgress size={18} sx={{ color: 'common.white' }} />
</Box>
) : (
<Typography variant="body2" sx={{ opacity: 0.7, mr: 2 }}>
No profile
</Typography>
);
}
return (
<Select
size="small"
value={activeProfileId ?? ''}
onChange={(e) => setActiveProfile(e.target.value)}
sx={{
mr: 2,
color: 'common.white',
'.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' }}
/>
)}
</MenuItem>
))}
</Select>
);
};
export default ProfileSwitcher;

View file

@ -6,43 +6,11 @@ import {
rewrapDek,
encryptJson,
decryptJson,
encrypt,
decrypt,
setEncKey,
clearEncKey,
hasEncKey,
generateIdentityKeyPair,
wrapIdentityPrivateKey,
unwrapIdentityPrivateKey,
setIdentityPrivate,
clearIdentityPrivate,
hasIdentityPrivate,
generateProfileDek,
wrapProfileDek,
unwrapProfileDek,
wrapProfileDekToRecipient,
unwrapProfileDekFromShare,
setProfileDek,
getProfileDek,
clearProfileDeks,
} from './index';
/** X25519 in Web Crypto is a recent addition (Node 16+, modern browsers). Some
* test runtimes (older jsdom/Node) may not implement it; detect once and skip
* the identity-keypair tests rather than fail spuriously. */
async function x25519Supported(): Promise<boolean> {
try {
await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'X25519' },
true,
['deriveBits'],
);
return true;
} catch {
return false;
}
}
describe('zero-knowledge crypto lifecycle', () => {
const password = 'my-secret-password';
const recoveryPhrase = 'my-recovery-phrase';
@ -131,205 +99,3 @@ describe('zero-knowledge crypto lifecycle', () => {
await expect(decryptJson(encrypted, setupB.dek)).rejects.toThrow();
});
});
describe('account X25519 identity keypair (Phase A1)', () => {
const password = 'my-secret-password';
it('full lifecycle: generate → wrap → unlock DEK → unwrap → deriveBits', async () => {
if (!(await x25519Supported())) {
console.log('[crypto] skipped (X25519 unsupported in this runtime)');
return;
}
// 1. Register: set up the account DEK and generate the identity keypair.
const setup = await setupEncryption(password);
const { publicKey, privateKey } = await generateIdentityKeyPair();
expect(publicKey).not.toBe('');
setIdentityPrivate(privateKey);
expect(hasIdentityPrivate()).toBe(true);
// 2. Wrap the private key under the account DEK and "store" it.
const wrapped = await wrapIdentityPrivateKey(privateKey, setup.dek);
expect(wrapped.data).not.toBe('');
// 3. Simulate page reload: both keys are lost from memory.
clearIdentityPrivate();
expect(hasIdentityPrivate()).toBe(false);
// 4. Unlock the DEK with the password (unlock screen flow).
const { dek } = await unlockWithPassword(password, setup.passwordWrappedDek);
// 5. Unwrap the identity private key from the stored wrapped form.
const recovered = await unwrapIdentityPrivateKey(wrapped, dek);
setIdentityPrivate(recovered);
expect(hasIdentityPrivate()).toBe(true);
// 6. The recovered private key is a USABLE X25519 key: it can derive a
// shared secret against the original public key. Import the public key
// and ECDH-derive — this proves the round-trip preserved a working key.
const pubBytes = Uint8Array.from(atob(publicKey), (c) => c.charCodeAt(0));
const pubKey = await crypto.subtle.importKey(
'raw',
pubBytes as BufferSource,
{ name: 'ECDH', namedCurve: 'X25519' },
false,
[],
);
const sharedSecret = await crypto.subtle.deriveBits(
{ name: 'ECDH', public: pubKey },
recovered,
256,
);
expect(sharedSecret.byteLength).toBe(32);
clearIdentityPrivate();
});
it('a wrapped private key cannot be unwrapped with the wrong DEK', async () => {
if (!(await x25519Supported())) {
console.log('[crypto] skipped (X25519 unsupported in this runtime)');
return;
}
// DEK for account A; a different DEK for account B.
const setupA = await setupEncryption('user-a-password');
const setupB = await setupEncryption('user-b-password');
const { privateKey } = await generateIdentityKeyPair();
const wrapped = await wrapIdentityPrivateKey(privateKey, setupA.dek);
// User B's DEK cannot unwrap user A's identity private key.
await expect(unwrapIdentityPrivateKey(wrapped, setupB.dek)).rejects.toThrow();
});
it('two identity keypairs derive different shared secrets with the same peer', async () => {
if (!(await x25519Supported())) {
console.log('[crypto] skipped (X25519 unsupported in this runtime)');
return;
}
// Sanity: distinct keypairs must produce distinct ECDH outputs.
const peer = await generateIdentityKeyPair();
const peerPubBytes = Uint8Array.from(atob(peer.publicKey), (c) => c.charCodeAt(0));
const peerPub = await crypto.subtle.importKey(
'raw',
peerPubBytes as BufferSource,
{ name: 'ECDH', namedCurve: 'X25519' },
false,
[],
);
const a = await generateIdentityKeyPair();
const b = await generateIdentityKeyPair();
const secretA = await crypto.subtle.deriveBits(
{ name: 'ECDH', public: peerPub },
a.privateKey,
256,
);
const secretB = await crypto.subtle.deriveBits(
{ name: 'ECDH', public: peerPub },
b.privateKey,
256,
);
const aHex = Array.from(new Uint8Array(secretA)).map((x) => x.toString(16)).join('');
const bHex = Array.from(new Uint8Array(secretB)).map((x) => x.toString(16)).join('');
expect(aHex).not.toBe(bHex);
});
});
describe('per-profile DEK isolation (Phase A2)', () => {
const password = 'my-secret-password';
it('two profiles get distinct DEKs, each wrapped under the account DEK', async () => {
// One account DEK (unlocked from the password).
const setup = await setupEncryption(password);
const accountDek = setup.dek;
// Two profiles, each with its own random DEK.
const childDek = await generateProfileDek();
const petDek = await generateProfileDek();
// Each profile DEK is wrapped under the account DEK for storage.
const childWrapped = await wrapProfileDek(childDek, accountDek);
const petWrapped = await wrapProfileDek(petDek, accountDek);
// Both wrapped blobs decrypt cleanly under the account DEK.
const childUnwrapped = await unwrapProfileDek(childWrapped, accountDek);
const petUnwrapped = await unwrapProfileDek(petWrapped, accountDek);
expect(childUnwrapped).toBeDefined();
expect(petUnwrapped).toBeDefined();
// Data encrypted under the child profile's DEK cannot be decrypted by the
// pet profile's DEK (and vice versa) — this is the per-profile isolation
// guarantee that makes the parent/child and pet cases safe.
const childData = await encrypt('child-medication', childDek);
await expect(decrypt(childData, petDek)).rejects.toThrow();
const petData = await encrypt('pet-weight', petDek);
await expect(decrypt(petData, childDek)).rejects.toThrow();
// ...but each decrypts with its own key.
expect(await decrypt(childData, childDek)).toBe('child-medication');
expect(await decrypt(petData, petDek)).toBe('pet-weight');
});
it('the in-memory profile-DEK store maps profile ids to DEKs', async () => {
const childDek = await generateProfileDek();
const petDek = await generateProfileDek();
setProfileDek('profile_child', childDek);
setProfileDek('profile_pet', petDek);
// Each id resolves to the DEK that was stored under it; unknown ids resolve to null.
expect(getProfileDek('profile_child')).toBe(childDek);
expect(getProfileDek('profile_pet')).toBe(petDek);
expect(getProfileDek('profile_unknown')).toBeNull();
// Clearing drops everything.
clearProfileDeks();
expect(getProfileDek('profile_child')).toBeNull();
});
});
describe('profile sharing: X25519 envelope (Phase B)', () => {
it('owner wraps a profile DEK to a recipient, who unwraps it', async () => {
if (!(await x25519Supported())) {
console.log('[crypto] skipped (X25519 unsupported in this runtime)');
return;
}
// Two accounts, each with their own identity keypair.
const owner = await generateIdentityKeyPair();
const recipient = await generateIdentityKeyPair();
// A profile DEK the owner created (would normally be wrapped under the
// owner's account DEK too — irrelevant for this test).
const profileDek = await generateProfileDek();
// Owner wraps the profile DEK to the recipient's public key. Internally
// this derives a fresh ephemeral keypair + ECDH(recipient pub, ephemeral
// priv) and AES-GCM-wraps the DEK under the shared secret.
const envelope = await wrapProfileDekToRecipient(
profileDek,
recipient.publicKey,
owner.privateKey, // not actually used by wrap (only recipient pub matters)
);
expect(envelope.ephemeralPublicKey).not.toBe('');
expect(envelope.wrappedProfileDek.data).not.toBe('');
// Recipient unwraps with their private key + the envelope's ephemeral pub.
const recoveredDek = await unwrapProfileDekFromShare(
envelope.wrappedProfileDek,
envelope.ephemeralPublicKey,
recipient.privateKey,
);
expect(recoveredDek).toBeDefined();
// The recovered DEK encrypts/decrypts the same data the original did.
const data = await encrypt('shared-medication', profileDek);
expect(await decrypt(data, recoveredDek)).toBe('shared-medication');
// A *different* recipient's private key cannot unwrap the share.
const stranger = await generateIdentityKeyPair();
await expect(
unwrapProfileDekFromShare(
envelope.wrappedProfileDek,
envelope.ephemeralPublicKey,
stranger.privateKey,
),
).rejects.toThrow();
});
});

View file

@ -8,29 +8,10 @@ export {
setupEncryption,
unlockWithPassword,
unlockWithRecovery,
generateIdentityKeyPair,
wrapIdentityPrivateKey,
unwrapIdentityPrivateKey,
generateProfileDek,
wrapProfileDek,
unwrapProfileDek,
wrapProfileDekToRecipient,
unwrapProfileDekFromShare,
type SharedProfileDekEnvelope,
setEncKey,
getEncKey,
clearEncKey,
hasEncKey,
setIdentityPrivate,
getIdentityPrivate,
clearIdentityPrivate,
hasIdentityPrivate,
setProfileDek,
getProfileDek,
setActiveProfileId,
getActiveProfileId,
getActiveProfileDek,
clearProfileDeks,
AUTH_SALT,
PASSWORD_KEK_SALT,
RECOVERY_KEK_SALT,

View file

@ -114,130 +114,6 @@ export async function rewrapDek(
return wrapDek(dek, newKek);
}
// ---------------------------------------------------------------------------
// Per-profile DEK wrap/unwrap (Phase A2).
//
// Each profile owns a random AES-256-GCM DEK that encrypts all of that
// profile's data. The profile DEK is wrapped under the *account* DEK (not a
// KEK derived from a secret) and stored on the server. At login/unlock the
// client unwraps each profile DEK using the account DEK. See
// docs/adr/multi-person-sharing.md §1.
// ---------------------------------------------------------------------------
/** Generate a fresh random profile DEK (a random AES-256-GCM key). */
export async function generateProfileDek(): Promise<CryptoKey> {
return generateDek();
}
/** Wrap a profile DEK under the account DEK. The account DEK is a CryptoKey
* usable for AES-GCM encrypt/decrypt, so it serves directly as the wrap key. */
export async function wrapProfileDek(
profileDek: CryptoKey,
accountDek: CryptoKey,
): Promise<CipherPayload> {
return wrapDek(profileDek, accountDek);
}
/** Unwrap a profile DEK from its account-wrapped form. Inverse of
* wrapProfileDek. Throws on tamper / wrong account DEK. */
export async function unwrapProfileDek(
payload: CipherPayload,
accountDek: CryptoKey,
): Promise<CryptoKey> {
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
// ---------------------------------------------------------------------------
@ -309,62 +185,6 @@ export async function unlockWithRecovery(
return unwrapDek(recoveryWrappedDek, recoveryKek);
}
// ---------------------------------------------------------------------------
// Account X25519 identity keypair (Phase A1, multi-person sharing prep).
//
// Each account gets an X25519 keypair at registration. The PUBLIC half is
// stored in plaintext (public keys are not secret); other accounts will wrap
// profile DEKs to it in Phase B. The PRIVATE half is wrapped (AES-GCM
// encrypted) under the account DEK and stored on the server as opaque
// ciphertext. The server never sees the private key.
//
// See docs/adr/multi-person-sharing.md §1.
// ---------------------------------------------------------------------------
/** Generate an X25519 keypair for the account. Returns the public key as a
* base64 raw string (for the server to store) and the private key as a
* CryptoKey (kept in memory, wrapped before upload). */
export async function generateIdentityKeyPair(): Promise<{
publicKey: string;
privateKey: CryptoKey;
}> {
const { publicKey, privateKey } = (await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'X25519' },
true, // extractable so we can export the private key for wrapping
['deriveBits'],
)) as CryptoKeyPair;
const rawPublic = await crypto.subtle.exportKey('raw', publicKey);
return { publicKey: base64(new Uint8Array(rawPublic)), privateKey };
}
/** Wrap (encrypt) the X25519 private key under the account DEK. The DEK is an
* AES-GCM key, so we reuse the string-cipher from cipher.ts: export the private
* key to raw bytes, base64-encode, and encrypt. */
export async function wrapIdentityPrivateKey(
privateKey: CryptoKey,
dek: CryptoKey,
): Promise<CipherPayload> {
const rawPrivate = await crypto.subtle.exportKey('raw', privateKey);
return encrypt(base64(new Uint8Array(rawPrivate)), dek);
}
/** Unwrap (decrypt) the X25519 private key from its DEK-wrapped form. Inverse
* of wrapIdentityPrivateKey. The result is importable for ECDH deriveBits. */
export async function unwrapIdentityPrivateKey(
payload: CipherPayload,
dek: CryptoKey,
): Promise<CryptoKey> {
const rawPrivateB64 = await decrypt(payload, dek);
const rawPrivate = Uint8Array.from(atob(rawPrivateB64), (c) => c.charCodeAt(0));
return crypto.subtle.importKey(
'raw',
rawPrivate as BufferSource,
{ name: 'ECDH', namedCurve: 'X25519' },
true, // extractable so it can be re-wrapped if ever needed
['deriveBits'],
);
}
// ---------------------------------------------------------------------------
// In-memory key store (unchanged from Phase 1)
// ---------------------------------------------------------------------------
@ -387,67 +207,6 @@ export function hasEncKey(): boolean {
return currentEncKey !== null;
}
// ---------------------------------------------------------------------------
// In-memory identity-private-key store (Phase A1). Same lifecycle as the DEK:
// held only for the authenticated session, cleared on logout, re-derived from
// the wrapped form (under the account DEK) on unlock. Not consumed until Phase B.
// ---------------------------------------------------------------------------
let currentIdentityPrivate: CryptoKey | null = null;
export function setIdentityPrivate(key: CryptoKey): void {
currentIdentityPrivate = key;
}
export function getIdentityPrivate(): CryptoKey | null {
return currentIdentityPrivate;
}
export function clearIdentityPrivate(): void {
currentIdentityPrivate = null;
}
export function hasIdentityPrivate(): boolean {
return currentIdentityPrivate !== null;
}
// ---------------------------------------------------------------------------
// In-memory per-profile DEK store (Phase A2). One DEK per profile, plus a
// notion of the "active" profile whose DEK encrypts/decrypts the data the UI
// is currently showing. Same lifecycle as the account DEK: rebuilt from the
// server-stored wrapped forms on unlock, cleared on logout.
// ---------------------------------------------------------------------------
const profileDeks: Map<string, CryptoKey> = new Map();
let activeProfileId: string | null = null;
export function setProfileDek(profileId: string, dek: CryptoKey): void {
profileDeks.set(profileId, dek);
}
export function getProfileDek(profileId: string): CryptoKey | null {
return profileDeks.get(profileId) ?? null;
}
export function setActiveProfileId(profileId: string): void {
activeProfileId = profileId;
}
export function getActiveProfileId(): string | null {
return activeProfileId;
}
/** The active profile's DEK — the key all encrypt/decrypt call sites use. */
export function getActiveProfileDek(): CryptoKey | null {
if (activeProfileId === null) return null;
return profileDeks.get(activeProfileId) ?? null;
}
export function clearProfileDeks(): void {
profileDeks.clear();
activeProfileId = null;
}
// ---------------------------------------------------------------------------
// Backward compat: the old deriveAuthAndEncKeys (Phase 1 direct-from-password
// enc key). Kept for tests; new code uses setupEncryption/unlockWithPassword.

View file

@ -6,7 +6,6 @@ import { MedicationManager } from '../components/medication/MedicationManager';
import { HealthStats } from '../components/health/HealthStats';
import { InteractionsChecker } from '../components/interactions/InteractionsChecker';
import { ProfileEditor } from '../components/profile/ProfileEditor';
import { ProfileSwitcher } from '../components/profile/ProfileSwitcher';
import { AppointmentsManager } from '../components/appointments/AppointmentsManager';
type TabIndex = 0 | 1 | 2 | 3 | 4;
@ -36,7 +35,6 @@ export const Dashboard: FC = () => {
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
Normogen
</Typography>
<ProfileSwitcher />
{user && (
<Typography variant="body2" sx={{ mr: 2, opacity: 0.85 }}>
{user.username}

View file

@ -12,23 +12,11 @@ import {
} from '@mui/material';
import { Lock as LockIcon } from '@mui/icons-material';
import { useAuthStore } from '../store/useStore';
import {
unlockWithPassword,
deriveAuthAndEncKeys,
unwrapIdentityPrivateKey,
setEncKey,
setIdentityPrivate,
} from '../crypto';
import { unlockWithPassword, deriveAuthAndEncKeys, setEncKey } from '../crypto';
export const UnlockPage: FC = () => {
const navigate = useNavigate();
const {
wrapped_dek,
wrapped_dek_iv,
identity_private_key_wrapped,
identity_private_key_wrapped_iv,
user,
} = useAuthStore();
const { wrapped_dek, wrapped_dek_iv, user } = useAuthStore();
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [unlocking, setUnlocking] = useState(false);
@ -39,32 +27,13 @@ export const UnlockPage: FC = () => {
setUnlocking(true);
try {
let dek;
if (wrapped_dek && wrapped_dek_iv) {
// Wrapped-DEK model: unwrap the DEK using the password.
({ dek } = await unlockWithPassword(password, {
const { dek } = await unlockWithPassword(password, {
data: wrapped_dek,
iv: wrapped_dek_iv,
}));
});
setEncKey(dek);
// Phase A1: with the DEK available, also unwrap the X25519 identity
// private key into in-memory storage. Non-fatal if it fails (e.g.
// legacy accounts without an identity keypair).
if (identity_private_key_wrapped && identity_private_key_wrapped_iv) {
try {
const identityPrivate = await unwrapIdentityPrivateKey(
{
data: identity_private_key_wrapped,
iv: identity_private_key_wrapped_iv,
},
dek,
);
setIdentityPrivate(identityPrivate);
} catch {
// Wrapped private key present but undecryptable — ignore; Phase B
// features will surface a re-setup need.
}
}
} else {
// Phase 1 compat: derive the key directly from the password.
const { encKey } = await deriveAuthAndEncKeys(password);

View file

@ -18,14 +18,6 @@ import {
MedicationWireResponse,
AppointmentWireResponse,
ProfileWireResponse,
CreateProfileRequest,
UpdateProfileRequest,
SharedProfileWireResponse,
CreateProfileShareRequest,
ProfileShareListing,
PublicKeyResponse,
RekeyRequest,
RekeyResponse,
HealthStatWireResponse,
UpdateHealthStatRequest,
EncryptedFieldWire,
@ -240,93 +232,25 @@ class ApiService {
});
}
// ---- Profiles (Phase A2: multi-profile, per-profile DEKs) ----
// ---- Profile (zero-knowledge: opaque encrypted name) ----
async listProfiles(): Promise<ProfileWireResponse[]> {
const response = await this.client.get<ProfileWireResponse[]>('/profiles');
async getProfile(): Promise<ProfileWireResponse> {
const response = await this.client.get<ProfileWireResponse>('/profiles/me');
return response.data;
}
async createProfile(req: CreateProfileRequest): Promise<ProfileWireResponse> {
const response = await this.client.post<ProfileWireResponse>('/profiles', req);
return response.data;
}
async getProfile(profileId: string): Promise<ProfileWireResponse> {
const response = await this.client.get<ProfileWireResponse>(`/profiles/${profileId}`);
return response.data;
}
async updateProfile(profileId: string, req: UpdateProfileRequest): Promise<ProfileWireResponse> {
const response = await this.client.put<ProfileWireResponse>(`/profiles/${profileId}`, req);
return response.data;
}
async deleteProfile(profileId: string): Promise<void> {
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 },
async updateProfileName(nameData: string, nameIv: string): Promise<ProfileWireResponse> {
const response = await this.client.put<ProfileWireResponse>('/profiles/me', {
name_data: nameData,
name_iv: nameIv,
});
return response.data;
}
/** Profiles shared TO the current user. Carries ECDH-wrapped profile DEKs. */
async listSharedWithMe(): Promise<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[]> {
const params = profileId ? { profile_id: profileId } : undefined;
const response = await this.client.get<MedicationWireResponse[]>('/medications', { params });
async getMedications(): Promise<MedicationWireResponse[]> {
const response = await this.client.get<MedicationWireResponse[]>('/medications');
return response.data;
}
@ -363,12 +287,9 @@ class ApiService {
// ---- Appointments (zero-knowledge: opaque encrypted blobs) ----
async getAppointments(status?: string, profileId?: string): Promise<AppointmentWireResponse[]> {
const params: Record<string, string> = {};
if (status) params.status = status;
if (profileId) params.profile_id = profileId;
async getAppointments(status?: string): Promise<AppointmentWireResponse[]> {
const response = await this.client.get<AppointmentWireResponse[]>('/appointments', {
params: Object.keys(params).length ? params : undefined,
params: status ? { status } : undefined,
});
return response.data;
}
@ -411,9 +332,8 @@ class ApiService {
// ---- Health Statistics (zero-knowledge: opaque encrypted blobs) ----
async getHealthStats(profileId?: string): Promise<HealthStatWireResponse[]> {
const params = profileId ? { profile_id: profileId } : undefined;
const response = await this.client.get<HealthStatWireResponse[]>('/health-stats', { params });
async getHealthStats(): Promise<HealthStatWireResponse[]> {
const response = await this.client.get<HealthStatWireResponse[]>('/health-stats');
return response.data;
}

View file

@ -3,9 +3,6 @@ import {
deriveAuthAndEncKeys,
setEncKey,
clearEncKey,
setActiveProfileId,
setProfileDek,
clearProfileDeks,
encryptJson,
setupEncryption,
unlockWithPassword,
@ -25,10 +22,6 @@ vi.mock('../services/api', () => ({ default: apiMock }));
// Import the store AFTER the mock is registered.
const { useMedicationStore } = await import('./useStore');
// The active profile under test — the store encrypts/decrypts with the active
// profile's DEK, so tests must set both the profile id and its DEK.
const TEST_PROFILE_ID = 'profile_test';
describe('useMedicationStore', () => {
let encKey: CryptoKey;
@ -37,10 +30,6 @@ describe('useMedicationStore', () => {
const { encKey: key } = await deriveAuthAndEncKeys('test-password');
encKey = key;
setEncKey(encKey);
// Phase A2: the store uses the active profile's DEK, not the account DEK.
// Register the test key as the active profile's DEK.
setProfileDek(TEST_PROFILE_ID, encKey);
setActiveProfileId(TEST_PROFILE_ID);
useMedicationStore.setState({
medications: [],
@ -55,7 +44,6 @@ describe('useMedicationStore', () => {
afterEach(() => {
clearEncKey();
clearProfileDeks();
});
it('loadMedications decrypts wire responses into domain medications', async () => {
@ -86,7 +74,6 @@ describe('useMedicationStore', () => {
it('loadMedications sets an error when no enc key is available', async () => {
clearEncKey();
clearProfileDeks();
await useMedicationStore.getState().loadMedications();
expect(useMedicationStore.getState().error).toMatch(/No encryption key/);
});

View file

@ -6,28 +6,9 @@ import {
unlockWithPassword,
unlockWithRecovery,
rewrapDek,
generateIdentityKeyPair,
wrapIdentityPrivateKey,
unwrapIdentityPrivateKey,
generateProfileDek,
wrapProfileDek,
unwrapProfileDek,
wrapProfileDekToRecipient,
unwrapProfileDekFromShare,
encrypt as encryptRaw,
decrypt as decryptRaw,
setEncKey,
clearEncKey,
clearIdentityPrivate,
clearProfileDeks,
setIdentityPrivate,
getIdentityPrivate,
getEncKey,
getActiveProfileDek,
getActiveProfileId,
setActiveProfileId,
setProfileDek,
getProfileDek,
encryptJson,
decryptJson,
type CipherPayload,
@ -40,10 +21,6 @@ import {
DrugInteraction,
AdherenceStats,
Profile,
ProfileShareListing,
MedicationWireResponse,
AppointmentWireResponse,
HealthStatWireResponse,
Appointment,
CreateAppointmentRequest,
UpdateAppointmentRequest,
@ -61,12 +38,6 @@ interface AuthState {
// on page reload without a full re-login.
wrapped_dek: string | null;
wrapped_dek_iv: string | null;
// Account X25519 identity keypair (Phase A1). The public key is plaintext;
// the private key is the DEK-wrapped ciphertext blob. Persisted for the same
// reason as the wrapped DEK — the unlock screen unwraps it into memory.
identity_public_key: string | null;
identity_private_key_wrapped: string | null;
identity_private_key_wrapped_iv: string | null;
// Actions
login: (email: string, password: string) => Promise<void>;
@ -124,46 +95,11 @@ interface InteractionState {
}
interface ProfileState {
profiles: Profile[];
activeProfileId: string | null;
profile: Profile | null;
isLoading: boolean;
error: string | null;
/** Fetch all owned profiles and unwrap each per-profile DEK under the account
* DEK into the in-memory crypto store. Sets the first profile active. */
loadProfiles: () => Promise<void>;
/** Switch the active profile (whose DEK encrypts/decrypts the UI's data). */
setActiveProfile: (profileId: string) => void;
createProfile: (input: {
name: string;
kind?: string;
relationship?: string;
}) => Promise<void>;
updateProfile: (profileId: string, input: {
name: string;
kind?: string;
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>;
loadProfile: () => Promise<void>;
updateName: (name: string) => Promise<void>;
clearError: () => void;
}
@ -192,9 +128,6 @@ export const useAuthStore = create<AuthState>()(
error: null,
wrapped_dek: null,
wrapped_dek_iv: null,
identity_public_key: null,
identity_private_key_wrapped: null,
identity_private_key_wrapped_iv: null,
login: async (email: string, password: string) => {
set({ isLoading: true, error: null });
@ -211,26 +144,6 @@ export const useAuthStore = create<AuthState>()(
iv: response.wrapped_dek_iv,
});
setEncKey(dek);
// Once the DEK is available, also unwrap the X25519 identity
// private key (Phase A1) into in-memory storage.
if (
response.identity_private_key_wrapped &&
response.identity_private_key_wrapped_iv
) {
try {
const identityPrivate = await unwrapIdentityPrivateKey(
{
data: response.identity_private_key_wrapped,
iv: response.identity_private_key_wrapped_iv,
},
dek,
);
setIdentityPrivate(identityPrivate);
} catch {
// Wrapped private key present but failed to unwrap —
// non-fatal; Phase B features will surface a re-setup need.
}
}
} catch {
// Fallback to the PBKDF2-derived key (Phase 1 compat).
}
@ -247,9 +160,6 @@ export const useAuthStore = create<AuthState>()(
isLoading: false,
wrapped_dek: response.wrapped_dek ?? null,
wrapped_dek_iv: response.wrapped_dek_iv ?? null,
identity_public_key: response.identity_public_key ?? null,
identity_private_key_wrapped: response.identity_private_key_wrapped ?? null,
identity_private_key_wrapped_iv: response.identity_private_key_wrapped_iv ?? null,
});
} catch (error: any) {
clearEncKey();
@ -271,21 +181,6 @@ export const useAuthStore = create<AuthState>()(
const setup = await setupEncryption(password, recoveryPhrase);
setEncKey(setup.dek);
// Phase A1: generate the account X25519 identity keypair and wrap
// the private half under the account DEK. The public key is sent
// plaintext; the wrapped private key is opaque ciphertext.
const { publicKey, privateKey } = await generateIdentityKeyPair();
const wrappedPrivate = await wrapIdentityPrivateKey(privateKey, setup.dek);
setIdentityPrivate(privateKey);
// Phase A2: generate the default "self" profile's DEK, wrap it
// under the account DEK, and encrypt the display name (the chosen
// username) under the profile DEK. The server stores both opaque
// blobs verbatim and auto-creates the self profile.
const defaultProfileDek = await generateProfileDek();
const defaultWrapped = await wrapProfileDek(defaultProfileDek, setup.dek);
const defaultNameBlob = await encryptRaw(username, defaultProfileDek);
const response = await apiService.register({
username,
email,
@ -295,19 +190,7 @@ export const useAuthStore = create<AuthState>()(
recovery_wrapped_dek: setup.recoveryWrappedDek?.data,
recovery_wrapped_dek_iv: setup.recoveryWrappedDek?.iv,
recovery_phrase: setup.recoveryKekHash,
identity_public_key: publicKey,
identity_private_key_wrapped: wrappedPrivate.data,
identity_private_key_wrapped_iv: wrappedPrivate.iv,
default_profile_name_data: defaultNameBlob.data,
default_profile_name_iv: defaultNameBlob.iv,
default_wrapped_profile_dek: defaultWrapped.data,
default_wrapped_profile_dek_iv: defaultWrapped.iv,
});
// Seed the in-memory profile-DEK store with the self profile and
// make it the active profile.
const selfProfileId = `profile_${response.user_id}`;
setProfileDek(selfProfileId, defaultProfileDek);
setActiveProfileId(selfProfileId);
} as any);
set({
user: {
user_id: response.user_id,
@ -320,9 +203,6 @@ export const useAuthStore = create<AuthState>()(
isLoading: false,
wrapped_dek: setup.passwordWrappedDek.data,
wrapped_dek_iv: setup.passwordWrappedDek.iv,
identity_public_key: publicKey,
identity_private_key_wrapped: wrappedPrivate.data,
identity_private_key_wrapped_iv: wrappedPrivate.iv,
});
} catch (error: any) {
set({
@ -374,8 +254,6 @@ export const useAuthStore = create<AuthState>()(
logout: async () => {
clearEncKey();
clearIdentityPrivate();
clearProfileDeks();
await apiService.logout();
set({
user: null,
@ -384,9 +262,6 @@ export const useAuthStore = create<AuthState>()(
error: null,
wrapped_dek: null,
wrapped_dek_iv: null,
identity_public_key: null,
identity_private_key_wrapped: null,
identity_private_key_wrapped_iv: null,
});
},
@ -421,9 +296,6 @@ export const useAuthStore = create<AuthState>()(
isAuthenticated: state.isAuthenticated,
wrapped_dek: state.wrapped_dek,
wrapped_dek_iv: state.wrapped_dek_iv,
identity_public_key: state.identity_public_key,
identity_private_key_wrapped: state.identity_private_key_wrapped,
identity_private_key_wrapped_iv: state.identity_private_key_wrapped_iv,
}),
}
)
@ -440,14 +312,14 @@ export const useMedicationStore = create<MedicationState>()(
adherence: {},
loadMedications: async () => {
const key = getActiveProfileDek();
const key = getEncKey();
if (!key) {
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
return;
}
set({ isLoading: true, error: null });
try {
const wireMeds = await apiService.getMedications(getActiveProfileId() ?? undefined);
const wireMeds = await apiService.getMedications();
// Decrypt each opaque blob into domain Medication objects.
const medications = await Promise.all(
wireMeds.map(async (w) => {
@ -487,7 +359,7 @@ export const useMedicationStore = create<MedicationState>()(
},
createMedication: async (data: any) => {
const key = getActiveProfileDek();
const key = getEncKey();
if (!key) {
set({ error: 'No encryption key — cannot encrypt data' });
throw new Error('No encryption key');
@ -537,7 +409,7 @@ export const useMedicationStore = create<MedicationState>()(
},
updateMedication: async (id: string, data: any) => {
const key = getActiveProfileDek();
const key = getEncKey();
if (!key) {
set({ error: 'No encryption key — cannot encrypt data' });
throw new Error('No encryption key');
@ -636,14 +508,14 @@ export const useHealthStore = create<HealthState>()(
error: null,
loadStats: async () => {
const key = getActiveProfileDek();
const key = getEncKey();
if (!key) {
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
return;
}
set({ isLoading: true, error: null });
try {
const wireStats = await apiService.getHealthStats(getActiveProfileId() ?? undefined);
const wireStats = await apiService.getHealthStats();
const stats = await Promise.all(
wireStats.map(async (w) => {
const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key);
@ -670,7 +542,7 @@ export const useHealthStore = create<HealthState>()(
},
createStat: async (data: any) => {
const key = getActiveProfileDek();
const key = getEncKey();
if (!key) {
set({ error: 'No encryption key — cannot encrypt data' });
throw new Error('No encryption key');
@ -678,7 +550,6 @@ export const useHealthStore = create<HealthState>()(
set({ isLoading: true, error: null });
try {
const d = data as any;
const profileId = getActiveProfileId();
const encrypted_data = await encryptJson({
stat_type: d.stat_type,
value: d.value,
@ -686,7 +557,6 @@ export const useHealthStore = create<HealthState>()(
notes: d.notes,
}, key);
const wire = await apiService.createHealthStat({
profile_id: profileId ?? '',
encrypted_data,
recorded_at: d.measured_at,
});
@ -713,7 +583,7 @@ export const useHealthStore = create<HealthState>()(
},
updateStat: async (id: string, data: any) => {
const key = getActiveProfileDek();
const key = getEncKey();
if (!key) {
set({ error: 'No encryption key — cannot encrypt data' });
throw new Error('No encryption key');
@ -825,159 +695,71 @@ export const useInteractionStore = create<InteractionState>()(
}))
);
// Profile Store (Phase A2: multi-profile + per-profile DEKs)
// Profile Store (Phase 3c)
export const useProfileStore = create<ProfileState>()(
devtools((set, get) => ({
profiles: [],
activeProfileId: null,
profile: null,
isLoading: false,
error: null,
profileShares: {},
loadProfiles: async () => {
const accountDek = getEncKey();
if (!accountDek) {
set({ error: 'No account key — log in to decrypt profiles', isLoading: false });
loadProfile: async () => {
const key = getEncKey();
if (!key) {
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
return;
}
set({ isLoading: true, error: null });
try {
const wireList = await apiService.listProfiles();
// Unwrap each profile's DEK under the account DEK, and decrypt each
// profile's display name under that profile's DEK.
const profiles: Profile[] = [];
for (const wire of wireList) {
let profileDek = getProfileDek(wire.profile_id);
if (!profileDek) {
try {
profileDek = await unwrapProfileDek(
{ data: wire.wrapped_profile_dek, iv: wire.wrapped_profile_dek_iv },
accountDek,
);
setProfileDek(wire.profile_id, profileDek);
} catch {
// Could not unwrap this profile's DEK — skip it; the UI will
// show the profiles it could decrypt.
continue;
}
}
let name = '';
if (wire.name_data && wire.name_iv && profileDek) {
try {
name = await decryptRaw({ data: wire.name_data, iv: wire.name_iv }, profileDek);
} catch { name = ''; }
}
profiles.push({
profile_id: wire.profile_id,
owner_account_id: wire.owner_account_id,
name,
kind: wire.kind,
relationship: wire.relationship,
role: wire.role,
permissions: wire.permissions,
created_at: wire.created_at,
updated_at: wire.updated_at,
});
const wire = await apiService.getProfile();
// Decrypt the profile name.
let name = '';
if (wire.name_data && wire.name_iv) {
try {
name = await decryptJson<string>({ data: wire.name_data, iv: wire.name_iv }, key);
} catch { name = ''; }
}
// Set the first profile active if none is selected (or if the active
// one is no longer present).
const currentActive = getActiveProfileId();
const stillOwned = profiles.some((p) => p.profile_id === currentActive);
const newActive = stillOwned ? currentActive : (profiles[0]?.profile_id ?? null);
if (newActive) setActiveProfileId(newActive);
set({ profiles, activeProfileId: newActive, isLoading: false });
} catch (error: any) {
set({
error: error.message || 'Failed to load profiles',
isLoading: false,
});
}
},
setActiveProfile: (profileId) => {
setActiveProfileId(profileId);
set({ activeProfileId: profileId });
},
createProfile: async ({ name, kind = 'human', relationship = '' }) => {
const accountDek = getEncKey();
if (!accountDek) {
set({ error: 'No account key — cannot create profile' });
throw new Error('No account key');
}
set({ isLoading: true, error: null });
try {
// Generate a fresh profile DEK, wrap it under the account DEK, and
// encrypt the display name under the new profile DEK.
const profileDek = await generateProfileDek();
const wrapped = await wrapProfileDek(profileDek, accountDek);
const nameBlob = await encryptRaw(name, profileDek);
const wire = await apiService.createProfile({
kind,
relationship,
name_data: nameBlob.data,
name_iv: nameBlob.iv,
wrapped_profile_dek: wrapped.data,
wrapped_profile_dek_iv: wrapped.iv,
});
// Cache the DEK in memory (we already have it) and set this profile
// active — the user just created it.
setProfileDek(wire.profile_id, profileDek);
setActiveProfileId(wire.profile_id);
const profile: Profile = {
profile_id: wire.profile_id,
owner_account_id: wire.owner_account_id,
user_id: wire.user_id,
name,
kind: wire.kind,
relationship: wire.relationship,
role: wire.role,
permissions: wire.permissions,
created_at: wire.created_at,
updated_at: wire.updated_at,
};
set((state) => ({
profiles: [...state.profiles, profile],
activeProfileId: wire.profile_id,
isLoading: false,
}));
set({ profile, isLoading: false });
} catch (error: any) {
set({
error: error.message || 'Failed to create profile',
error: error.message || 'Failed to load profile',
isLoading: false,
});
throw error;
}
},
updateProfile: async (profileId, { name, kind, relationship }) => {
const profileDek = getProfileDek(profileId);
if (!profileDek) {
set({ error: 'No profile key — switch to or unlock this profile first' });
throw new Error('No profile key');
updateName: async (name) => {
const key = getEncKey();
if (!key) {
set({ error: 'No encryption key — cannot encrypt data' });
throw new Error('No encryption key');
}
set({ isLoading: true, error: null });
try {
const nameBlob = await encryptRaw(name, profileDek);
const wire = await apiService.updateProfile(profileId, {
name_data: nameBlob.data,
name_iv: nameBlob.iv,
kind: kind ?? 'human',
relationship: relationship ?? '',
});
set((state) => ({
profiles: state.profiles.map((p) =>
p.profile_id === profileId
? {
...p,
name,
kind: wire.kind,
relationship: wire.relationship,
updated_at: wire.updated_at,
}
: p,
),
isLoading: false,
}));
const blob = await encryptJson(name, key);
const wire = await apiService.updateProfileName(blob.data, blob.iv);
let decryptedName = name;
try {
decryptedName = await decryptJson<string>({ data: wire.name_data, iv: wire.name_iv }, key);
} catch { /* keep the input name */ }
const profile: Profile = {
profile_id: wire.profile_id,
user_id: wire.user_id,
name: decryptedName,
role: wire.role,
permissions: wire.permissions,
created_at: wire.created_at,
updated_at: wire.updated_at,
};
set({ profile, isLoading: false });
} catch (error: any) {
set({
error: error.message || 'Failed to update profile',
@ -987,272 +769,6 @@ export const useProfileStore = create<ProfileState>()(
}
},
deleteProfile: async (profileId) => {
set({ isLoading: true, error: null });
try {
await apiService.deleteProfile(profileId);
const remaining = get().profiles.filter((p) => p.profile_id !== profileId);
// If we deleted the active profile, fall back to the first remaining.
let newActive = getActiveProfileId();
if (newActive === profileId) {
newActive = remaining[0]?.profile_id ?? null;
if (newActive) setActiveProfileId(newActive);
}
set({ profiles: remaining, activeProfileId: newActive, isLoading: false });
} catch (error: any) {
set({
error: error.message || 'Failed to delete profile',
isLoading: false,
});
throw error;
}
},
loadSharedWithMe: async () => {
// Recipient path: profiles shared TO the current user. Each share's
// profile DEK is wrapped under an ECDH-derived key; unwrap it with the
// identity private key, then decrypt the display name under the profile
// DEK. Merge into the profiles list with is_shared = true.
const myIdentityPrivate = getIdentityPrivate();
if (!myIdentityPrivate) {
// No identity key unlocked — can't unwrap shared profile DEKs. Silently
// skip; the owned-profile list still loads.
return;
}
try {
const shared = await apiService.listSharedWithMe();
const sharedProfiles: Profile[] = [];
for (const s of shared) {
// Skip if we already have the DEK in memory (avoid re-unwrapping).
let profileDek = getProfileDek(s.profile_id);
if (!profileDek) {
try {
profileDek = await unwrapProfileDekFromShare(
{ data: s.wrapped_profile_dek, iv: s.wrapped_profile_dek_iv },
s.ephemeral_public_key,
myIdentityPrivate,
);
setProfileDek(s.profile_id, profileDek);
} catch {
continue; // couldn't unwrap — skip this share
}
}
let name = '';
if (s.name_data && s.name_iv && profileDek) {
try {
name = await decryptRaw({ data: s.name_data, iv: s.name_iv }, profileDek);
} catch { name = ''; }
}
sharedProfiles.push({
profile_id: s.profile_id,
owner_account_id: s.owner_account_id,
name,
kind: s.kind,
relationship: s.relationship,
role: 'patient',
permissions: s.permissions,
created_at: s.created_at,
updated_at: s.created_at,
is_shared: true,
});
}
// Merge: replace any prior shared entries, keep owned ones intact.
const owned = get().profiles.filter((p) => !p.is_shared);
const merged = [...owned, ...sharedProfiles];
set({ profiles: merged });
} catch (error: any) {
// Non-fatal: owned profiles still work.
set({ error: error.message || 'Failed to load shared profiles' });
}
},
shareProfile: async (profileId, recipientEmail) => {
// Owner path: fetch recipient pubkey, wrap the profile DEK to it, POST.
const myIdentityPrivate = getIdentityPrivate();
const profileDek = getProfileDek(profileId);
if (!myIdentityPrivate) {
set({ error: 'No identity key unlocked — cannot share' });
throw new Error('No identity key');
}
if (!profileDek) {
set({ error: 'No profile key — switch to the profile first' });
throw new Error('No profile key');
}
set({ isLoading: true, error: null });
try {
const { identity_public_key: recipientPub } =
await apiService.getUserPublicKey(recipientEmail);
const envelope = await wrapProfileDekToRecipient(
profileDek,
recipientPub,
myIdentityPrivate,
);
await apiService.createProfileShare(profileId, {
recipient_email: recipientEmail,
ephemeral_public_key: envelope.ephemeralPublicKey,
wrapped_profile_dek: envelope.wrappedProfileDek.data,
wrapped_profile_dek_iv: envelope.wrappedProfileDek.iv,
permissions: ['read'],
});
// Refresh the share listing for this profile.
await get().loadProfileShares(profileId);
set({ isLoading: false });
} catch (error: any) {
set({
error: error.message || 'Failed to share profile',
isLoading: false,
});
throw error;
}
},
revokeShare: async (profileId, recipientUserId) => {
set({ isLoading: true, error: null });
try {
await apiService.deleteProfileShare(profileId, recipientUserId);
await get().loadProfileShares(profileId);
set({ isLoading: false });
} catch (error: any) {
set({
error: error.message || 'Failed to revoke share',
isLoading: false,
});
throw error;
}
},
loadProfileShares: async (profileId) => {
try {
const shares = await apiService.listProfileShares(profileId);
set((state) => ({
profileShares: { ...state.profileShares, [profileId]: shares },
}));
} catch (error: any) {
set({ error: error.message || 'Failed to load shares' });
}
},
rekeyProfile: async (profileId, keepRecipientUserIds) => {
// Phase C hard revoke. Re-encrypts all the profile's data client-side
// under a fresh DEK, then commits the rotation. Resume-safe per-row:
// if a row was already re-encrypted on a prior partial run, decrypt-
// with-old fails and we try decrypt-with-new; if that succeeds, the
// row is already on the new DEK and we skip it.
const accountDek = getEncKey();
const oldDek = getProfileDek(profileId);
const myIdentityPrivate = getIdentityPrivate();
if (!accountDek || !oldDek) {
set({ error: 'Unlock the profile first (no account/profile key)' });
throw new Error('No keys');
}
set({ isLoading: true, error: null });
try {
const newDek = await generateProfileDek();
// 3a. Re-encrypt every data row under the new DEK.
// medications + appointments: POST /:id with the new blob.
// health-stats: PUT /:id with the new blob.
const reencryptRow = async (
getBlob: () => Promise<{ data: string; iv: string } | null>,
reencrypt: (newBlob: { data: string; iv: string }) => Promise<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 }),
})),
);
@ -1265,14 +781,14 @@ export const useAppointmentStore = create<AppointmentState>()(
error: null,
loadAppointments: async (status) => {
const key = getActiveProfileDek();
const key = getEncKey();
if (!key) {
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
return;
}
set({ isLoading: true, error: null });
try {
const wireAppts = await apiService.getAppointments(status, getActiveProfileId() ?? undefined);
const wireAppts = await apiService.getAppointments(status);
const appointments = await Promise.all(
wireAppts.map(async (w) => {
const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key);
@ -1305,7 +821,7 @@ export const useAppointmentStore = create<AppointmentState>()(
},
createAppointment: async (data) => {
const key = getActiveProfileDek();
const key = getEncKey();
if (!key) {
set({ error: 'No encryption key — cannot encrypt data' });
throw new Error('No encryption key');
@ -1351,7 +867,7 @@ export const useAppointmentStore = create<AppointmentState>()(
},
updateAppointment: async (id, data) => {
const key = getActiveProfileDek();
const key = getEncKey();
if (!key) {
set({ error: 'No encryption key — cannot encrypt data' });
throw new Error('No encryption key');

View file

@ -42,11 +42,6 @@ export interface AuthTokens {
email?: string;
wrapped_dek?: string;
wrapped_dek_iv?: string;
/** Account X25519 identity keypair (Phase A1). Public key is plaintext;
* private key is wrapped under the account DEK. Absent for legacy accounts. */
identity_public_key?: string;
identity_private_key_wrapped?: string;
identity_private_key_wrapped_iv?: string;
}
export interface LoginRequest {
@ -59,25 +54,6 @@ export interface RegisterRequest {
email: string;
password: string;
role?: 'patient' | 'provider' | 'admin';
/** Zero-knowledge wrapped DEK (password KEK). */
wrapped_dek?: string;
wrapped_dek_iv?: string;
/** Zero-knowledge wrapped DEK (recovery KEK). */
recovery_wrapped_dek?: string;
recovery_wrapped_dek_iv?: string;
/** Recovery proof (PBKDF2 derivation of the recovery phrase). */
recovery_phrase?: string;
/** Account X25519 identity keypair (Phase A1). */
identity_public_key?: string;
identity_private_key_wrapped?: string;
identity_private_key_wrapped_iv?: string;
/** Default "self" profile (Phase A2). Encrypted name blob + profile DEK
* wrapped under the account DEK. */
default_profile_name_data?: string;
default_profile_name_iv?: string;
default_profile_name_auth_tag?: string;
default_wrapped_profile_dek?: string;
default_wrapped_profile_dek_iv?: string;
}
// Medication Types
@ -247,13 +223,11 @@ export interface HealthStat {
export interface HealthStatWireResponse {
id: string;
user_id: string;
profile_id: string;
encrypted_data: EncryptedFieldWire;
recorded_at: string;
}
export interface CreateHealthStatRequest {
profile_id: string;
encrypted_data: EncryptedFieldWire;
recorded_at?: string;
}
@ -333,110 +307,18 @@ export interface UpdateAppointmentRequest {
status?: string;
}
// Profile wire response (opaque encrypted name + wrapped profile DEK)
// Profile wire response (opaque encrypted name)
export interface ProfileWireResponse {
profile_id: string;
owner_account_id: string;
user_id: string;
name_data: string;
name_iv: string;
kind: string;
relationship: string;
role: string;
permissions: string[];
/** Profile DEK wrapped under the account DEK (opaque ciphertext). */
wrapped_profile_dek: string;
wrapped_profile_dek_iv: string;
created_at: string;
updated_at: string;
}
// Request body for POST /profiles
export interface CreateProfileRequest {
profile_id?: string;
kind?: string;
relationship?: string;
name_data: string;
name_iv: string;
name_auth_tag?: string;
wrapped_profile_dek: string;
wrapped_profile_dek_iv: string;
}
// Request body for PUT /profiles/:id
export interface UpdateProfileRequest {
name_data: string;
name_iv: string;
name_auth_tag?: string;
kind?: string;
relationship?: string;
}
// A profile shared TO the current user (recipient view). Carries the
// per-share ECDH-wrapped DEK the recipient unwraps with their identity
// private key (not the account-wrapped form owned profiles use).
export interface SharedProfileWireResponse {
profile_id: string;
owner_account_id: string;
name_data: string;
name_iv: string;
kind: string;
relationship: string;
ephemeral_public_key: string;
wrapped_profile_dek: string;
wrapped_profile_dek_iv: string;
permissions: string[];
created_at: string;
}
// Request body for POST /profiles/:id/shares (owner wraps client-side).
export interface CreateProfileShareRequest {
recipient_email: string;
ephemeral_public_key: string;
wrapped_profile_dek: string;
wrapped_profile_dek_iv: string;
permissions?: string[];
expires_at?: string;
}
// A share the owner has created (listing view).
export interface ProfileShareListing {
profile_id: string;
recipient_user_id: string;
recipient_email: string;
permissions: string[];
created_at: string;
active: boolean;
}
// Phase C hard revoke: one fresh ECDH envelope per recipient the owner keeps.
export interface RecipientEnvelope {
recipient_user_id: string;
ephemeral_public_key: string;
wrapped_profile_dek: string;
wrapped_profile_dek_iv: string;
}
// Request body for POST /profiles/:id/rekey (hard revoke / rotate the DEK).
export interface RekeyRequest {
new_wrapped_profile_dek: string;
new_wrapped_profile_dek_iv: string;
recipient_envelopes: RecipientEnvelope[];
}
// Response from POST /profiles/:id/rekey.
export interface RekeyResponse {
profile_id: string;
wrapped_profile_dek: string;
wrapped_profile_dek_iv: string;
retained_recipient_user_ids: string[];
}
// Response from GET /users/public-key?email=...
export interface PublicKeyResponse {
user_id: string;
identity_public_key: string;
}
// Dose Log Types — match backend MedicationDose (camelCase serialization).
export interface DoseLog {
id?: string;
@ -469,18 +351,12 @@ export interface AdherenceStats {
// Profile Types — match backend ProfileResponse.
export interface Profile {
profile_id: string;
owner_account_id: string;
user_id: string;
name: string;
kind: string;
relationship: string;
role: string;
permissions: string[];
created_at: string;
updated_at: string;
/** True if this profile is shared TO the current user (recipient view).
* Undefined/false for profiles the user owns. Set by the store when merging
* shared-with-me results. */
is_shared?: boolean;
}
// Error Types