Compare commits

..

No commits in common. "main" and "docs/3-4-encryption-persona-docs-reconciliation" have entirely different histories.

48 changed files with 1190 additions and 5035 deletions

View file

@ -111,12 +111,8 @@ jobs:
image: mongo:7
env:
MONGO_INITDB_DATABASE: normogen_test
# No `ports:` mapping: the job container reaches Mongo over the job's
# internal network via the service hostname (MONGODB_URI uses `mongo`,
# not localhost). Publishing to a host port is unnecessary and collides
# with any mongod already bound to :27017 on the runner host — which
# made the service container fail to schedule ("port is already
# allocated") and aborted the whole test job before any test ran.
ports:
- 27017:27017
env:
MONGODB_URI: mongodb://mongo:27017
APP_ENVIRONMENT: development

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" })),
));
match repo.find_by_appointment_id(&id).await {
Ok(Some(appt)) => Ok(Json(appt.into())),
Ok(None) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
Err(e) => {
tracing::error!("get_appointment failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
// Authorization: direct owner (user_id == caller) OR share-gate on the
// appointment's profile (admits an active share recipient).
if appt.user_id != claims.sub {
crate::handlers::profile_share::authorize_profile_read(&state, &claims, &appt.profile_id)
.await?;
}
Ok(Json(appt.into()))
}
pub async fn update_appointment(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Extension(_claims): Extension<Claims>,
Path(id): Path<String>,
Json(req): Json<UpdateAppointmentRequest>,
) -> Result<Json<AppointmentResponse>, (StatusCode, Json<serde_json::Value>)> {
) -> Result<Json<AppointmentResponse>, StatusCode> {
let database = state.db.get_database();
let repo = AppointmentRepository::new(database.collection("appointments"));
// Authorization: owner-only (recipients are read-only). 404 on mismatch to
// avoid leaking existence.
let existing = match repo.find_by_appointment_id(&id).await {
Ok(Some(a)) => a,
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "not found" })),
));
}
Err(e) => {
tracing::error!("update_appointment lookup failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
if existing.user_id != claims.sub {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "not found" })),
));
}
match repo.update_by_appointment_id(&id, req).await {
Ok(Some(appt)) => Ok(Json(appt.into())),
Ok(None) => Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "not found" })),
)),
Err(e) => {
tracing::error!("update_appointment failed: {}", e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
))
}
Ok(None) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
pub async fn delete_appointment(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Extension(_claims): Extension<Claims>,
Path(id): Path<String>,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
) -> Result<StatusCode, StatusCode> {
let database = state.db.get_database();
let repo = AppointmentRepository::new(database.collection("appointments"));
// Authorization: owner-only.
let existing = match repo.find_by_appointment_id(&id).await {
Ok(Some(a)) => a,
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "not found" })),
));
}
Err(e) => {
tracing::error!("delete_appointment lookup failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
if existing.user_id != claims.sub {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "not found" })),
));
}
match repo.delete_by_appointment_id(&id).await {
Ok(true) => Ok(StatusCode::NO_CONTENT),
Ok(false) => Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "not found" })),
)),
Err(e) => {
tracing::error!("delete_appointment failed: {}", e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
))
}
Ok(false) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}

View file

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

View file

@ -4,9 +4,10 @@ pub mod health;
pub mod health_stats;
pub mod interactions;
pub mod medications;
pub mod permissions;
pub mod profile;
pub mod profile_share;
pub mod sessions;
pub mod shares;
pub mod users;
// Re-export commonly used handler functions
@ -23,12 +24,10 @@ pub use medications::{
create_medication, delete_medication, get_adherence, get_medication, list_medications,
log_dose, update_medication,
};
pub use 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,
};
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
let database = state.db.get_database();
let repo = ProfileRepository::new(database.collection("profiles"));
let repo = profile_repo(&state);
if let Err(e) = repo.create(&profile).await {
tracing::error!("Profile create failed: {}", e);
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);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
Ok((StatusCode::CREATED, Json(ProfileResponse::from(profile))))
};
Ok(Json(ProfileResponse::from(profile)))
}
// ---------------------------------------------------------------------------
// GET /api/profiles/:id — fetch one owned profile
// ---------------------------------------------------------------------------
pub async fn get_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<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
.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" })),
)),
Err(e) => {
tracing::error!("Profile lookup failed: {}", e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
))
}
}
}
// ---------------------------------------------------------------------------
// 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(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Path(profile_id): Path<String>,
Json(req): Json<UpdateProfileRequest>,
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
let repo = profile_repo(&state);
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

@ -65,10 +65,10 @@ Note what is **absent** and why:
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.)
- `backend/src/auth/claims.rs` *does* define an `AccessClaims` struct with
`family_id`, `permissions`, `token_type`, `jti`. **It is dead code** — not
imported or used anywhere in the source (only in stale compiler scratch
files under `target/`). It should be removed or wired up; tracked by issue #4.
- **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.
@ -151,13 +151,13 @@ never on the server to protect:
- 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).
- The dead `AccessClaims` struct is a latent trap — someone could wire it up
assuming `family_id`/`permissions` are enforced. Should be deleted.
## 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.
- **Remove `backend/src/auth/claims.rs`** (dead `AccessClaims`) or wire it up
intentionally. Tracked under issue #4.
- 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.

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" />
) : (
<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
Account
</Typography>
<TextField
size="small"
fullWidth
value={relationship}
onChange={(e) => setRelationship(e.target.value)}
placeholder="self, child, spouse, parent, pet, ..."
sx={{ mt: 0.5 }}
/>
</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>
<Typography variant="caption" color="text.secondary">
Role
</Typography>
<Box sx={{ mt: 0.5 }}>
<Chip size="small" label={profile?.role ?? 'patient'} variant="outlined" />
</Box>
</>
)}
</>
)}
{/* Owner-only sharing UI. */}
{!editing && !active.is_shared && (
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider' }}>
<ProfileSharing profileId={active.profile_id} />
</Box>
)}
</Stack>
</CardContent>
</Card>
) : (
<Alert severity="info">
No profiles yet. Create one to start tracking health data.
</Alert>
)}
{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;
}
}
const wire = await apiService.getProfile();
// Decrypt the profile name.
let name = '';
if (wire.name_data && wire.name_iv && profileDek) {
if (wire.name_data && wire.name_iv) {
try {
name = await decryptRaw({ data: wire.name_data, iv: wire.name_iv }, profileDek);
name = await decryptJson<string>({ data: wire.name_data, iv: wire.name_iv }, key);
} 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,
});
}
// 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,
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,
}
: p,
),
isLoading: false,
}));
};
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