Compare commits
18 commits
ci/fix-mon
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dabbea2483 | |||
|
|
6d6af67f2f | ||
|
|
bf5aeedbc2 | ||
|
|
668ea59030 | ||
| 76fd5a8c26 | |||
|
|
ff185a60e4 | ||
| d59487fa3b | |||
|
|
d0efecf38f | ||
|
|
e28135bc08 | ||
|
|
04520539aa | ||
| 015a99a7fe | |||
| 7409077355 | |||
|
|
706df11f15 | ||
|
|
eb2c2aa546 | ||
| 9807434c5f | |||
| 6d3642ee13 | |||
|
|
8ee8012aca | ||
|
|
e322145ffb |
52 changed files with 5767 additions and 2179 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,6 +2,7 @@
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
.forgejo-token
|
.forgejo-token
|
||||||
|
.zcode/
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
target/
|
target/
|
||||||
|
|
|
||||||
|
|
@ -35,28 +35,39 @@ pub fn build_app(state: AppState) -> Router {
|
||||||
"/api/auth/recover-password",
|
"/api/auth/recover-password",
|
||||||
post(handlers::recover_password),
|
post(handlers::recover_password),
|
||||||
)
|
)
|
||||||
.route("/api/auth/recovery-info", get(handlers::recovery_info));
|
.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));
|
||||||
|
|
||||||
// Build protected routes (auth required)
|
// Build protected routes (auth required)
|
||||||
let protected_routes = Router::new()
|
let protected_routes = Router::new()
|
||||||
// User profile management
|
// User profile management
|
||||||
.route("/api/users/me", get(handlers::get_profile))
|
.route("/api/users/me", get(handlers::get_account))
|
||||||
.route("/api/users/me", put(handlers::update_profile))
|
.route("/api/users/me", put(handlers::update_account))
|
||||||
.route("/api/users/me", delete(handlers::delete_account))
|
.route("/api/users/me", delete(handlers::delete_account))
|
||||||
.route("/api/users/me/change-password", post(handlers::change_password))
|
.route("/api/users/me/change-password", post(handlers::change_password))
|
||||||
// User settings
|
// User settings
|
||||||
.route("/api/users/me/settings", get(handlers::get_settings))
|
.route("/api/users/me/settings", get(handlers::get_settings))
|
||||||
.route("/api/users/me/settings", put(handlers::update_settings))
|
.route("/api/users/me/settings", put(handlers::update_settings))
|
||||||
// Share management
|
// Profile management (Phase A2 multi-profile + Phase B sharing).
|
||||||
.route("/api/shares", post(handlers::create_share))
|
// GET /api/profiles/:id is share-aware (admits recipients); the other
|
||||||
.route("/api/shares", get(handlers::list_shares))
|
// profile ops remain owner-only.
|
||||||
.route("/api/shares/:id", put(handlers::update_share))
|
.route("/api/profiles", get(handlers::list_profiles))
|
||||||
.route("/api/shares/:id", delete(handlers::delete_share))
|
.route("/api/profiles", post(handlers::create_profile))
|
||||||
// Permission checking
|
.route("/api/profiles/shared-with-me", get(handlers::list_shared_with_me))
|
||||||
.route("/api/permissions/check", post(handlers::check_permission))
|
.route("/api/profiles/:id", get(handlers::get_profile_shared_aware))
|
||||||
// Profile management (Phase 3c)
|
.route("/api/profiles/:id", put(handlers::update_profile))
|
||||||
.route("/api/profiles/me", get(handlers::get_my_profile))
|
.route("/api/profiles/:id", delete(handlers::delete_profile))
|
||||||
.route("/api/profiles/me", put(handlers::update_my_profile))
|
// Profile sharing (Phase B): owner manages shares; recipients read via
|
||||||
|
// the gate inside the data handlers.
|
||||||
|
.route(
|
||||||
|
"/api/profiles/:id/shares",
|
||||||
|
get(handlers::list_shares).post(handlers::create_share),
|
||||||
|
)
|
||||||
|
.route("/api/profiles/:id/shares/:recipient", delete(handlers::delete_share))
|
||||||
|
// Hard revoke / rotate the profile DEK (Phase C). Owner-only.
|
||||||
|
.route("/api/profiles/:id/rekey", post(handlers::rekey_profile))
|
||||||
// Session management (Phase 2.6)
|
// Session management (Phase 2.6)
|
||||||
.route("/api/sessions", get(handlers::get_sessions))
|
.route("/api/sessions", get(handlers::get_sessions))
|
||||||
.route("/api/sessions/:id", delete(handlers::revoke_session))
|
.route("/api/sessions/:id", delete(handlers::revoke_session))
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use pbkdf2::{
|
use pbkdf2::{
|
||||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
password_hash::{
|
||||||
|
rand_core::OsRng, Error as PasswordHashError, PasswordHash, PasswordHasher,
|
||||||
|
PasswordVerifier, SaltString,
|
||||||
|
},
|
||||||
Pbkdf2,
|
Pbkdf2,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -15,14 +18,24 @@ impl PasswordService {
|
||||||
Ok(password_hash.to_string())
|
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> {
|
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
|
||||||
let parsed_hash = PasswordHash::new(hash)
|
let parsed_hash = PasswordHash::new(hash)
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to parse password hash: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Failed to parse password hash: {}", e))?;
|
||||||
|
|
||||||
Pbkdf2
|
match Pbkdf2.verify_password(password.as_bytes(), &parsed_hash) {
|
||||||
.verify_password(password.as_bytes(), &parsed_hash)
|
Ok(()) => Ok(true),
|
||||||
.map(|_| true)
|
// `Error::Password` is the password-hash crate's signal for a
|
||||||
.map_err(|e| anyhow::anyhow!("Password verification failed: {}", e))
|
// mismatch — the one expected "failure" during normal auth.
|
||||||
|
Err(PasswordHashError::Password) => Ok(false),
|
||||||
|
Err(e) => Err(anyhow::anyhow!("Password verification failed: {}", e)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,15 +98,30 @@ impl DatabaseInitializer {
|
||||||
println!("✓ Created appointments collection");
|
println!("✓ Created appointments collection");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create shares collection and index
|
// 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).
|
||||||
{
|
{
|
||||||
let collection: Collection<mongodb::bson::Document> = self.db.collection("shares");
|
let collection: Collection<mongodb::bson::Document> =
|
||||||
|
self.db.collection("profile_shares");
|
||||||
|
|
||||||
let index = IndexModel::builder().keys(doc! { "familyId": 1 }).build();
|
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),
|
||||||
|
}
|
||||||
|
|
||||||
match collection.create_index(index, None).await {
|
let recipient_index = IndexModel::builder()
|
||||||
Ok(_) => println!("✓ Created index on shares.familyId"),
|
.keys(doc! { "recipientUserId": 1 })
|
||||||
Err(e) => println!("Warning: Failed to create index on shares.familyId: {}", e),
|
.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
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ use std::time::Duration;
|
||||||
|
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
medication::{Medication, MedicationDose, MedicationRepository, UpdateMedicationRequest},
|
medication::{Medication, MedicationDose, MedicationRepository, UpdateMedicationRequest},
|
||||||
permission::Permission,
|
|
||||||
share::{Share, ShareRepository},
|
|
||||||
user::{User, UserRepository},
|
user::{User, UserRepository},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -14,7 +12,6 @@ use crate::models::{
|
||||||
pub struct MongoDb {
|
pub struct MongoDb {
|
||||||
database: Database,
|
database: Database,
|
||||||
pub users: Collection<User>,
|
pub users: Collection<User>,
|
||||||
pub shares: Collection<Share>,
|
|
||||||
pub medications: Collection<Medication>,
|
pub medications: Collection<Medication>,
|
||||||
pub medication_doses: Collection<MedicationDose>,
|
pub medication_doses: Collection<MedicationDose>,
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +59,6 @@ impl MongoDb {
|
||||||
|
|
||||||
return Ok(Self {
|
return Ok(Self {
|
||||||
users: database.collection("users"),
|
users: database.collection("users"),
|
||||||
shares: database.collection("shares"),
|
|
||||||
medications: database.collection("medications"),
|
medications: database.collection("medications"),
|
||||||
medication_doses: database.collection("medication_doses"),
|
medication_doses: database.collection("medication_doses"),
|
||||||
database,
|
database,
|
||||||
|
|
@ -97,7 +93,6 @@ impl MongoDb {
|
||||||
|
|
||||||
return Ok(Self {
|
return Ok(Self {
|
||||||
users: database.collection("users"),
|
users: database.collection("users"),
|
||||||
shares: database.collection("shares"),
|
|
||||||
medications: database.collection("medications"),
|
medications: database.collection("medications"),
|
||||||
medication_doses: database.collection("medication_doses"),
|
medication_doses: database.collection("medication_doses"),
|
||||||
database,
|
database,
|
||||||
|
|
@ -113,7 +108,6 @@ impl MongoDb {
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
users: database.collection("users"),
|
users: database.collection("users"),
|
||||||
shares: database.collection("shares"),
|
|
||||||
medications: database.collection("medications"),
|
medications: database.collection("medications"),
|
||||||
medication_doses: database.collection("medication_doses"),
|
medication_doses: database.collection("medication_doses"),
|
||||||
database,
|
database,
|
||||||
|
|
@ -177,100 +171,6 @@ impl MongoDb {
|
||||||
Ok(())
|
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) =====
|
// ===== Medication Methods (Fixed for Phase 2.8) =====
|
||||||
|
|
||||||
pub async fn create_medication(&self, medication: &Medication) -> Result<Option<ObjectId>> {
|
pub async fn create_medication(&self, medication: &Medication) -> Result<Option<ObjectId>> {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ use mongodb::bson::DateTime;
|
||||||
pub struct AppointmentListQuery {
|
pub struct AppointmentListQuery {
|
||||||
/// Filter by status (upcoming/completed/cancelled). Optional.
|
/// Filter by status (upcoming/completed/cancelled). Optional.
|
||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
|
/// Filter by profile id. Optional.
|
||||||
|
pub profile_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_appointment(
|
pub async fn create_appointment(
|
||||||
|
|
@ -63,64 +65,175 @@ pub async fn list_appointments(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(claims): Extension<Claims>,
|
Extension(claims): Extension<Claims>,
|
||||||
Query(query): Query<AppointmentListQuery>,
|
Query(query): Query<AppointmentListQuery>,
|
||||||
) -> Result<Json<Vec<AppointmentResponse>>, StatusCode> {
|
) -> Result<Json<Vec<AppointmentResponse>>, (StatusCode, Json<serde_json::Value>)> {
|
||||||
let database = state.db.get_database();
|
let database = state.db.get_database();
|
||||||
let repo = AppointmentRepository::new(database.collection("appointments"));
|
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
|
match repo
|
||||||
.find_by_user_filtered(&claims.sub, query.status.as_deref())
|
.find_by_user_filtered(&owner, query.status.as_deref(), query.profile_id.as_deref())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(appointments) => {
|
Ok(appointments) => {
|
||||||
let resp: Vec<AppointmentResponse> = appointments.into_iter().map(Into::into).collect();
|
let resp: Vec<AppointmentResponse> = appointments.into_iter().map(Into::into).collect();
|
||||||
Ok(Json(resp))
|
Ok(Json(resp))
|
||||||
}
|
}
|
||||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
Err(e) => {
|
||||||
|
tracing::error!("list_appointments failed: {}", e);
|
||||||
|
Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({ "error": "database error" })),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_appointment(
|
pub async fn get_appointment(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(_claims): Extension<Claims>,
|
Extension(claims): Extension<Claims>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<AppointmentResponse>, StatusCode> {
|
) -> Result<Json<AppointmentResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||||
let database = state.db.get_database();
|
let database = state.db.get_database();
|
||||||
let repo = AppointmentRepository::new(database.collection("appointments"));
|
let repo = AppointmentRepository::new(database.collection("appointments"));
|
||||||
|
|
||||||
match repo.find_by_appointment_id(&id).await {
|
let appt = match repo.find_by_appointment_id(&id).await {
|
||||||
Ok(Some(appt)) => Ok(Json(appt.into())),
|
Ok(Some(a)) => a,
|
||||||
Ok(None) => Err(StatusCode::NOT_FOUND),
|
Ok(None) => {
|
||||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
return Err((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(serde_json::json!({ "error": "not found" })),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("get_appointment failed: {}", e);
|
||||||
|
return Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({ "error": "database error" })),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Authorization: direct owner (user_id == caller) OR share-gate on the
|
||||||
|
// appointment's profile (admits an active share recipient).
|
||||||
|
if appt.user_id != claims.sub {
|
||||||
|
crate::handlers::profile_share::authorize_profile_read(&state, &claims, &appt.profile_id)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
|
Ok(Json(appt.into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_appointment(
|
pub async fn update_appointment(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(_claims): Extension<Claims>,
|
Extension(claims): Extension<Claims>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(req): Json<UpdateAppointmentRequest>,
|
Json(req): Json<UpdateAppointmentRequest>,
|
||||||
) -> Result<Json<AppointmentResponse>, StatusCode> {
|
) -> Result<Json<AppointmentResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||||
let database = state.db.get_database();
|
let database = state.db.get_database();
|
||||||
let repo = AppointmentRepository::new(database.collection("appointments"));
|
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 {
|
match repo.update_by_appointment_id(&id, req).await {
|
||||||
Ok(Some(appt)) => Ok(Json(appt.into())),
|
Ok(Some(appt)) => Ok(Json(appt.into())),
|
||||||
Ok(None) => Err(StatusCode::NOT_FOUND),
|
Ok(None) => Err((
|
||||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
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" })),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_appointment(
|
pub async fn delete_appointment(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(_claims): Extension<Claims>,
|
Extension(claims): Extension<Claims>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<StatusCode, StatusCode> {
|
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
|
||||||
let database = state.db.get_database();
|
let database = state.db.get_database();
|
||||||
let repo = AppointmentRepository::new(database.collection("appointments"));
|
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 {
|
match repo.delete_by_appointment_id(&id).await {
|
||||||
Ok(true) => Ok(StatusCode::NO_CONTENT),
|
Ok(true) => Ok(StatusCode::NO_CONTENT),
|
||||||
Ok(false) => Err(StatusCode::NOT_FOUND),
|
Ok(false) => Err((
|
||||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
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" })),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,20 @@ pub struct RegisterRequest {
|
||||||
/// Wrapped DEK (base64 ciphertext) — encrypted under the recovery KEK
|
/// Wrapped DEK (base64 ciphertext) — encrypted under the recovery KEK
|
||||||
pub recovery_wrapped_dek: Option<String>,
|
pub recovery_wrapped_dek: Option<String>,
|
||||||
pub recovery_wrapped_dek_iv: 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)]
|
#[derive(Debug, Serialize)]
|
||||||
|
|
@ -40,6 +54,12 @@ pub struct AuthResponse {
|
||||||
/// user has no wrapped DEK (legacy/pre-recovery accounts).
|
/// user has no wrapped DEK (legacy/pre-recovery accounts).
|
||||||
pub wrapped_dek: Option<String>,
|
pub wrapped_dek: Option<String>,
|
||||||
pub wrapped_dek_iv: 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(
|
pub async fn register(
|
||||||
|
|
@ -108,6 +128,11 @@ pub async fn register(
|
||||||
user.recovery_wrapped_dek = req.recovery_wrapped_dek.clone();
|
user.recovery_wrapped_dek = req.recovery_wrapped_dek.clone();
|
||||||
user.recovery_wrapped_dek_iv = req.recovery_wrapped_dek_iv.clone();
|
user.recovery_wrapped_dek_iv = req.recovery_wrapped_dek_iv.clone();
|
||||||
user.recovery_enabled = req.recovery_wrapped_dek.is_some();
|
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
|
// Get token_version before saving
|
||||||
let token_version = user.token_version;
|
let token_version = user.token_version;
|
||||||
|
|
@ -129,17 +154,29 @@ pub async fn register(
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-create a default profile for the new user. The profile_id is
|
// Auto-create the default "self" profile for the new user, but only
|
||||||
// deterministic (profile_<user_id>) and is the contract the frontend
|
// when the client provided a wrapped profile DEK. The profile_id is
|
||||||
// uses for medication creation. Best-effort: registration still
|
// deterministic (profile_<owner>). Best-effort: registration still
|
||||||
// succeeds if this fails (GET /profiles/me lazily creates one).
|
// succeeds if this fails or if no wrapped DEK was provided (the
|
||||||
let database = state.db.get_database();
|
// client creates the profile via POST /api/profiles on first run).
|
||||||
let profile_repo =
|
if let (Some(wrapped_dek), Some(wrapped_dek_iv)) = (
|
||||||
crate::models::profile::ProfileRepository::new(database.collection("profiles"));
|
req.default_wrapped_profile_dek.as_ref(),
|
||||||
let profile =
|
req.default_wrapped_profile_dek_iv.as_ref(),
|
||||||
crate::handlers::profile::build_default_profile(&id.to_string(), &req.username);
|
) {
|
||||||
if let Err(e) = profile_repo.create(&profile).await {
|
let database = state.db.get_database();
|
||||||
tracing::warn!("Failed to auto-create profile for {}: {}", id, e);
|
let profile_repo =
|
||||||
|
crate::models::profile::ProfileRepository::new(database.collection("profiles"));
|
||||||
|
let profile = crate::handlers::profile::build_default_profile(
|
||||||
|
&id.to_string(),
|
||||||
|
wrapped_dek,
|
||||||
|
wrapped_dek_iv,
|
||||||
|
req.default_profile_name_data.as_deref().unwrap_or(""),
|
||||||
|
req.default_profile_name_iv.as_deref().unwrap_or(""),
|
||||||
|
req.default_profile_name_auth_tag.as_deref().unwrap_or(""),
|
||||||
|
);
|
||||||
|
if let Err(e) = profile_repo.create(&profile).await {
|
||||||
|
tracing::warn!("Failed to auto-create profile for {}: {}", id, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
id
|
id
|
||||||
|
|
@ -203,6 +240,9 @@ pub async fn register(
|
||||||
username: user.username.clone(),
|
username: user.username.clone(),
|
||||||
wrapped_dek: user.wrapped_dek.clone(),
|
wrapped_dek: user.wrapped_dek.clone(),
|
||||||
wrapped_dek_iv: user.wrapped_dek_iv.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()
|
(StatusCode::CREATED, Json(response)).into_response()
|
||||||
|
|
@ -413,6 +453,9 @@ pub async fn login(
|
||||||
username: user.username.clone(),
|
username: user.username.clone(),
|
||||||
wrapped_dek: user.wrapped_dek.clone(),
|
wrapped_dek: user.wrapped_dek.clone(),
|
||||||
wrapped_dek_iv: user.wrapped_dek_iv.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()
|
(StatusCode::OK, Json(response)).into_response()
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use crate::config::AppState;
|
||||||
use crate::models::health_stats::{HealthStatResponse, HealthStatistic};
|
use crate::models::health_stats::{HealthStatResponse, HealthStatistic};
|
||||||
use crate::models::medication::EncryptedFieldWire;
|
use crate::models::medication::EncryptedFieldWire;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
Extension, Json,
|
Extension, Json,
|
||||||
|
|
@ -14,9 +14,16 @@ use serde::Deserialize;
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateHealthStatRequest {
|
pub struct CreateHealthStatRequest {
|
||||||
pub encrypted_data: EncryptedFieldWire,
|
pub encrypted_data: EncryptedFieldWire,
|
||||||
|
/// Which profile this stat belongs to.
|
||||||
|
pub profile_id: String,
|
||||||
pub recorded_at: Option<String>,
|
pub recorded_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
pub struct ListHealthStatsQuery {
|
||||||
|
pub profile_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateHealthStatRequest {
|
pub struct UpdateHealthStatRequest {
|
||||||
pub encrypted_data: EncryptedFieldWire,
|
pub encrypted_data: EncryptedFieldWire,
|
||||||
|
|
@ -41,6 +48,7 @@ pub async fn create_health_stat(
|
||||||
let stat = HealthStatistic {
|
let stat = HealthStatistic {
|
||||||
id: None,
|
id: None,
|
||||||
user_id: claims.sub.clone(),
|
user_id: claims.sub.clone(),
|
||||||
|
profile_id: req.profile_id,
|
||||||
encrypted_data: req.encrypted_data,
|
encrypted_data: req.encrypted_data,
|
||||||
recorded_at: req
|
recorded_at: req
|
||||||
.recorded_at
|
.recorded_at
|
||||||
|
|
@ -70,6 +78,7 @@ pub async fn create_health_stat(
|
||||||
pub async fn list_health_stats(
|
pub async fn list_health_stats(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(claims): Extension<Claims>,
|
Extension(claims): Extension<Claims>,
|
||||||
|
Query(query): Query<ListHealthStatsQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let repo = match state.health_stats_repo.as_ref() {
|
let repo = match state.health_stats_repo.as_ref() {
|
||||||
Some(r) => r,
|
Some(r) => r,
|
||||||
|
|
@ -81,7 +90,34 @@ pub async fn list_health_stats(
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
match repo.find_by_user(&claims.sub).await {
|
// 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
|
||||||
|
{
|
||||||
Ok(stats) => {
|
Ok(stats) => {
|
||||||
let resp: Vec<HealthStatResponse> = stats.into_iter().map(Into::into).collect();
|
let resp: Vec<HealthStatResponse> = stats.into_iter().map(Into::into).collect();
|
||||||
(StatusCode::OK, Json(resp)).into_response()
|
(StatusCode::OK, Json(resp)).into_response()
|
||||||
|
|
@ -122,10 +158,21 @@ pub async fn get_health_stat(
|
||||||
|
|
||||||
match repo.find_by_id(&object_id).await {
|
match repo.find_by_id(&object_id).await {
|
||||||
Ok(Some(stat)) => {
|
Ok(Some(stat)) => {
|
||||||
if stat.user_id != claims.sub {
|
// Authorization: direct owner (user_id == caller) OR share-gate on
|
||||||
return (StatusCode::FORBIDDEN, "Access denied").into_response();
|
// 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(),
|
||||||
}
|
}
|
||||||
(StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response()
|
|
||||||
}
|
}
|
||||||
Ok(None) => (
|
Ok(None) => (
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
|
|
|
||||||
|
|
@ -64,70 +64,193 @@ pub async fn list_medications(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(claims): Extension<Claims>,
|
Extension(claims): Extension<Claims>,
|
||||||
Query(query): Query<ListMedicationsQuery>,
|
Query(query): Query<ListMedicationsQuery>,
|
||||||
) -> Result<Json<Vec<MedicationResponse>>, StatusCode> {
|
) -> Result<Json<Vec<MedicationResponse>>, (StatusCode, Json<serde_json::Value>)> {
|
||||||
let database = state.db.get_database();
|
let database = state.db.get_database();
|
||||||
let repo = MedicationRepository::new(database.collection("medications"));
|
let repo = MedicationRepository::new(database.collection("medications"));
|
||||||
|
|
||||||
// Honor the active filter (and optional profile_id) when provided.
|
// 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(),
|
||||||
|
};
|
||||||
|
|
||||||
match repo
|
match repo
|
||||||
.find_by_user_filtered(&claims.sub, query.active, query.profile_id.as_deref())
|
.find_by_user_filtered(&owner, query.active, query.profile_id.as_deref())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(medications) => {
|
Ok(medications) => {
|
||||||
let resp: Vec<MedicationResponse> = medications.into_iter().map(Into::into).collect();
|
let resp: Vec<MedicationResponse> = medications.into_iter().map(Into::into).collect();
|
||||||
Ok(Json(resp))
|
Ok(Json(resp))
|
||||||
}
|
}
|
||||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
Err(e) => {
|
||||||
|
tracing::error!("list_medications failed: {}", e);
|
||||||
|
Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({ "error": "database error" })),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_medication(
|
pub async fn get_medication(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(_claims): Extension<Claims>,
|
Extension(claims): Extension<Claims>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<MedicationResponse>, StatusCode> {
|
) -> Result<Json<MedicationResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||||
let database = state.db.get_database();
|
let database = state.db.get_database();
|
||||||
let repo = MedicationRepository::new(database.collection("medications"));
|
let repo = MedicationRepository::new(database.collection("medications"));
|
||||||
|
|
||||||
// The path param is the application-level medication_id (a UUID), not the
|
// 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.
|
// Mongo _id, so look it up directly instead of parsing as ObjectId.
|
||||||
match repo.find_by_medication_id(&id).await {
|
let medication = match repo.find_by_medication_id(&id).await {
|
||||||
Ok(Some(medication)) => Ok(Json(medication.into())),
|
Ok(Some(m)) => m,
|
||||||
Ok(None) => Err(StatusCode::NOT_FOUND),
|
Ok(None) => {
|
||||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
return Err((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(serde_json::json!({ "error": "not found" })),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("get_medication failed: {}", e);
|
||||||
|
return Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({ "error": "database error" })),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Authorization: admit the direct owner (user_id == caller) OR, if not,
|
||||||
|
// fall through to the share-gate on the medication's profile (admits an
|
||||||
|
// active share recipient). The direct-owner check preserves the original
|
||||||
|
// ownership model for medications whose profile_id may not map to a real
|
||||||
|
// Profile document (e.g. legacy/test data).
|
||||||
|
if medication.user_id != claims.sub {
|
||||||
|
crate::handlers::profile_share::authorize_profile_read(
|
||||||
|
&state,
|
||||||
|
&claims,
|
||||||
|
&medication.profile_id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
|
Ok(Json(medication.into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_medication(
|
pub async fn update_medication(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(_claims): Extension<Claims>,
|
Extension(claims): Extension<Claims>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(req): Json<UpdateMedicationRequest>,
|
Json(req): Json<UpdateMedicationRequest>,
|
||||||
) -> Result<Json<MedicationResponse>, StatusCode> {
|
) -> Result<Json<MedicationResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||||
let database = state.db.get_database();
|
let database = state.db.get_database();
|
||||||
let repo = MedicationRepository::new(database.collection("medications"));
|
let repo = MedicationRepository::new(database.collection("medications"));
|
||||||
|
|
||||||
// Look up by medication_id (UUID) — the path param is not a Mongo ObjectId.
|
// 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" })),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
match repo.update_by_medication_id(&id, req).await {
|
match repo.update_by_medication_id(&id, req).await {
|
||||||
Ok(Some(medication)) => Ok(Json(medication.into())),
|
Ok(Some(medication)) => Ok(Json(medication.into())),
|
||||||
Ok(None) => Err(StatusCode::NOT_FOUND),
|
Ok(None) => Err((
|
||||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
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" })),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_medication(
|
pub async fn delete_medication(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(_claims): Extension<Claims>,
|
Extension(claims): Extension<Claims>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<StatusCode, StatusCode> {
|
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
|
||||||
let database = state.db.get_database();
|
let database = state.db.get_database();
|
||||||
let repo = MedicationRepository::new(database.collection("medications"));
|
let repo = MedicationRepository::new(database.collection("medications"));
|
||||||
|
|
||||||
// Look up by medication_id (UUID), not Mongo _id.
|
// 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" })),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
match repo.delete_by_medication_id(&id).await {
|
match repo.delete_by_medication_id(&id).await {
|
||||||
Ok(true) => Ok(StatusCode::NO_CONTENT),
|
Ok(true) => Ok(StatusCode::NO_CONTENT),
|
||||||
Ok(false) => Err(StatusCode::NOT_FOUND),
|
Ok(false) => Err((
|
||||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
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" })),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,8 +259,34 @@ pub async fn log_dose(
|
||||||
Extension(claims): Extension<Claims>,
|
Extension(claims): Extension<Claims>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(req): Json<LogDoseRequest>,
|
Json(req): Json<LogDoseRequest>,
|
||||||
) -> Result<(StatusCode, Json<MedicationDose>), StatusCode> {
|
) -> Result<(StatusCode, Json<MedicationDose>), (StatusCode, Json<serde_json::Value>)> {
|
||||||
let database = state.db.get_database();
|
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();
|
let now = SystemTime::now();
|
||||||
|
|
||||||
|
|
@ -155,7 +304,13 @@ pub async fn log_dose(
|
||||||
.collection::<crate::models::medication::MedicationDose>("medication_doses")
|
.collection::<crate::models::medication::MedicationDose>("medication_doses")
|
||||||
.insert_one(&dose, None)
|
.insert_one(&dose, None)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|e| {
|
||||||
|
tracing::error!("log_dose insert failed: {}", e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({ "error": "database error" })),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
// Populate the generated _id so the caller gets the persisted dose back.
|
// Populate the generated _id so the caller gets the persisted dose back.
|
||||||
dose.id = result.inserted_id.as_object_id();
|
dose.id = result.inserted_id.as_object_id();
|
||||||
|
|
@ -165,9 +320,10 @@ pub async fn log_dose(
|
||||||
|
|
||||||
pub async fn get_adherence(
|
pub async fn get_adherence(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(_claims): Extension<Claims>,
|
Extension(claims): Extension<Claims>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<crate::models::medication::AdherenceStats>, StatusCode> {
|
) -> Result<Json<crate::models::medication::AdherenceStats>, (StatusCode, Json<serde_json::Value>)>
|
||||||
|
{
|
||||||
use mongodb::bson::{doc, DateTime};
|
use mongodb::bson::{doc, DateTime};
|
||||||
|
|
||||||
const PERIOD_DAYS: i64 = 30;
|
const PERIOD_DAYS: i64 = 30;
|
||||||
|
|
@ -176,6 +332,28 @@ pub async fn get_adherence(
|
||||||
let doses: mongodb::Collection<MedicationDose> = database.collection("medication_doses");
|
let doses: mongodb::Collection<MedicationDose> = database.collection("medication_doses");
|
||||||
let med_repo = MedicationRepository::new(database.collection("medications"));
|
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.
|
// Look at doses logged in the last PERIOD_DAYS days for this medication.
|
||||||
let since = DateTime::from_system_time(
|
let since = DateTime::from_system_time(
|
||||||
SystemTime::now() - std::time::Duration::from_secs(PERIOD_DAYS as u64 * 86400),
|
SystemTime::now() - std::time::Duration::from_secs(PERIOD_DAYS as u64 * 86400),
|
||||||
|
|
@ -188,19 +366,25 @@ pub async fn get_adherence(
|
||||||
let total_logged = doses
|
let total_logged = doses
|
||||||
.count_documents(filter.clone(), None)
|
.count_documents(filter.clone(), None)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|e| {
|
||||||
|
tracing::error!("get_adherence count failed: {}", e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({ "error": "database error" })),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let taken_filter = doc! { "$and": [filter, doc! { "taken": true }] };
|
let taken_filter = doc! { "$and": [filter, doc! { "taken": true }] };
|
||||||
let taken = doses
|
let taken = doses
|
||||||
.count_documents(taken_filter, None)
|
.count_documents(taken_filter, None)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|e| {
|
||||||
|
tracing::error!("get_adherence taken count failed: {}", e);
|
||||||
// Fetch the medication to check for a dose schedule.
|
(
|
||||||
let medication = med_repo
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
.find_by_medication_id(&id)
|
Json(serde_json::json!({ "error": "database error" })),
|
||||||
.await
|
)
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
})?;
|
||||||
|
|
||||||
let (scheduled_doses, total_doses, missed_doses, rate) =
|
let (scheduled_doses, total_doses, missed_doses, rate) =
|
||||||
match medication.and_then(|m| m.dose_schedule) {
|
match medication.and_then(|m| m.dose_schedule) {
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,9 @@ pub mod health;
|
||||||
pub mod health_stats;
|
pub mod health_stats;
|
||||||
pub mod interactions;
|
pub mod interactions;
|
||||||
pub mod medications;
|
pub mod medications;
|
||||||
pub mod permissions;
|
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
|
pub mod profile_share;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
pub mod shares;
|
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|
||||||
// Re-export commonly used handler functions
|
// Re-export commonly used handler functions
|
||||||
|
|
@ -24,10 +23,12 @@ pub use medications::{
|
||||||
create_medication, delete_medication, get_adherence, get_medication, list_medications,
|
create_medication, delete_medication, get_adherence, get_medication, list_medications,
|
||||||
log_dose, update_medication,
|
log_dose, update_medication,
|
||||||
};
|
};
|
||||||
pub use permissions::check_permission;
|
pub use profile::{create_profile, delete_profile, get_profile, list_profiles, update_profile};
|
||||||
pub use profile::{get_my_profile, update_my_profile};
|
pub use profile_share::{
|
||||||
pub use sessions::{get_sessions, revoke_all_sessions, revoke_session};
|
create_share, delete_share, get_profile_shared_aware, get_public_key, list_shared_with_me,
|
||||||
pub use shares::{create_share, delete_share, list_shares, update_share};
|
list_shares, rekey_profile,
|
||||||
pub use users::{
|
};
|
||||||
change_password, delete_account, get_profile, get_settings, update_profile, update_settings,
|
pub use sessions::{get_sessions, revoke_all_sessions, revoke_session};
|
||||||
|
pub use users::{
|
||||||
|
change_password, delete_account, get_account, get_settings, update_account, update_settings,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
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,
|
|
||||||
¶ms.resource_type,
|
|
||||||
¶ms.resource_id,
|
|
||||||
¶ms.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()
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Extension, State},
|
extract::{Extension, Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
|
|
@ -12,17 +12,21 @@ use crate::{
|
||||||
models::profile::{Profile, ProfileRepository},
|
models::profile::{Profile, ProfileRepository},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The profile as exposed to clients. The display name is returned as an opaque
|
/// The profile as exposed to clients. Display name + wrapped profile DEK are
|
||||||
/// client-encrypted blob (the server cannot read it).
|
/// returned as opaque client-encrypted blobs (the server cannot read either).
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct ProfileResponse {
|
pub struct ProfileResponse {
|
||||||
pub profile_id: String,
|
pub profile_id: String,
|
||||||
pub user_id: String,
|
pub owner_account_id: String,
|
||||||
/// Opaque client-encrypted display name.
|
|
||||||
pub name_data: String,
|
pub name_data: String,
|
||||||
pub name_iv: String,
|
pub name_iv: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub relationship: String,
|
||||||
pub role: String,
|
pub role: String,
|
||||||
pub permissions: Vec<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 created_at: DateTime,
|
||||||
pub updated_at: DateTime,
|
pub updated_at: DateTime,
|
||||||
}
|
}
|
||||||
|
|
@ -31,98 +35,212 @@ impl From<Profile> for ProfileResponse {
|
||||||
fn from(p: Profile) -> Self {
|
fn from(p: Profile) -> Self {
|
||||||
Self {
|
Self {
|
||||||
profile_id: p.profile_id,
|
profile_id: p.profile_id,
|
||||||
user_id: p.user_id,
|
owner_account_id: p.owner_account_id,
|
||||||
name_data: p.name,
|
name_data: p.name,
|
||||||
name_iv: p.name_iv,
|
name_iv: p.name_iv,
|
||||||
|
kind: p.kind,
|
||||||
|
relationship: p.relationship,
|
||||||
role: p.role,
|
role: p.role,
|
||||||
permissions: p.permissions,
|
permissions: p.permissions,
|
||||||
|
wrapped_profile_dek: p.wrapped_profile_dek,
|
||||||
|
wrapped_profile_dek_iv: p.wrapped_profile_dek_iv,
|
||||||
created_at: p.created_at,
|
created_at: p.created_at,
|
||||||
updated_at: p.updated_at,
|
updated_at: p.updated_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create the default "patient" profile for a freshly registered user. Called
|
/// Build a default "self" profile for a freshly registered user. Called from
|
||||||
/// from `register`. The profile_id is deterministic: `profile_<user_id>` — this
|
/// `register` when the client provides a wrapped profile DEK for the default
|
||||||
/// is the contract the frontend relies on for medication creation. The name is
|
/// profile. The server has no encryption key, so the name blob and wrapped
|
||||||
/// empty (not encrypted) because the server has no encryption key; the client
|
/// profile DEK are taken verbatim from the caller (the client generated the
|
||||||
/// sets it via PUT /profiles/me after deriving its key.
|
/// profile DEK and wrapped it under the account DEK).
|
||||||
pub fn build_default_profile(user_id: &str, _name: &str) -> Profile {
|
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 {
|
||||||
let now = DateTime::now();
|
let now = DateTime::now();
|
||||||
Profile {
|
Profile {
|
||||||
id: None,
|
id: None,
|
||||||
profile_id: format!("profile_{user_id}"),
|
// profile_<owner> keeps the deterministic-id contract the frontend
|
||||||
user_id: user_id.to_string(),
|
// 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(),
|
||||||
family_id: None,
|
family_id: None,
|
||||||
name: String::new(),
|
name: name_data.to_string(),
|
||||||
name_iv: String::new(),
|
name_iv: name_iv.to_string(),
|
||||||
name_auth_tag: String::new(),
|
name_auth_tag: name_auth_tag.to_string(),
|
||||||
|
kind: "human".to_string(),
|
||||||
|
relationship: "self".to_string(),
|
||||||
role: "patient".to_string(),
|
role: "patient".to_string(),
|
||||||
permissions: vec!["read:self".to_string(), "write:self".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,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PUT /profiles/me body — the client sends the encrypted name blob.
|
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.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateProfileNameRequest {
|
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 name_data: String,
|
pub name_data: String,
|
||||||
pub name_iv: String,
|
pub name_iv: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub name_auth_tag: String,
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/profiles/me — the current user's profile. If for some reason the
|
fn default_kind_value() -> String {
|
||||||
/// auto-created profile is missing, lazily create it.
|
"human".to_string()
|
||||||
pub async fn get_my_profile(
|
}
|
||||||
|
|
||||||
|
pub async fn create_profile(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(claims): Extension<Claims>,
|
Extension(claims): Extension<Claims>,
|
||||||
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
|
Json(req): Json<CreateProfileRequest>,
|
||||||
let database = state.db.get_database();
|
) -> Result<(StatusCode, Json<ProfileResponse>), (StatusCode, Json<serde_json::Value>)> {
|
||||||
let repo = ProfileRepository::new(database.collection("profiles"));
|
let now = DateTime::now();
|
||||||
|
let profile = Profile {
|
||||||
let profile = match repo.find_by_user_id(&claims.sub).await {
|
id: None,
|
||||||
Ok(Some(p)) => p,
|
profile_id: req
|
||||||
Ok(None) => {
|
.profile_id
|
||||||
// Lazily create if missing (e.g. users registered before this code shipped).
|
.unwrap_or_else(|| format!("p_{}", uuid::Uuid::new_v4())),
|
||||||
let p = build_default_profile(&claims.sub, &claims.sub);
|
owner_account_id: claims.sub.clone(),
|
||||||
if let Err(e) = repo.create(&p).await {
|
user_id: claims.sub.clone(),
|
||||||
tracing::error!("Failed to lazily create profile: {}", e);
|
family_id: None,
|
||||||
return Err((
|
name: req.name_data,
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
name_iv: req.name_iv,
|
||||||
Json(serde_json::json!({ "error": "failed to load profile" })),
|
name_auth_tag: req.name_auth_tag,
|
||||||
));
|
kind: req.kind,
|
||||||
}
|
relationship: req.relationship,
|
||||||
p
|
role: "patient".to_string(),
|
||||||
}
|
permissions: vec!["read:self".to_string(), "write:self".to_string()],
|
||||||
Err(e) => {
|
wrapped_profile_dek: req.wrapped_profile_dek,
|
||||||
tracing::error!("Profile lookup failed: {}", e);
|
wrapped_profile_dek_iv: req.wrapped_profile_dek_iv,
|
||||||
return Err((
|
created_at: now,
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
updated_at: now,
|
||||||
Json(serde_json::json!({ "error": "database error" })),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(ProfileResponse::from(profile)))
|
let repo = profile_repo(&state);
|
||||||
|
if let Err(e) = repo.create(&profile).await {
|
||||||
|
tracing::error!("Profile create failed: {}", e);
|
||||||
|
return Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({ "error": "database error" })),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok((StatusCode::CREATED, Json(ProfileResponse::from(profile))))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PUT /api/profiles/me — update the profile's encrypted display-name blob.
|
// ---------------------------------------------------------------------------
|
||||||
pub async fn update_my_profile(
|
// GET /api/profiles/:id — fetch one owned profile
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub async fn get_profile(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(claims): Extension<Claims>,
|
Extension(claims): Extension<Claims>,
|
||||||
Json(req): Json<UpdateProfileNameRequest>,
|
Path(profile_id): Path<String>,
|
||||||
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
|
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||||
let database = state.db.get_database();
|
let repo = profile_repo(&state);
|
||||||
let repo = ProfileRepository::new(database.collection("profiles"));
|
|
||||||
|
|
||||||
match repo
|
match repo
|
||||||
.update_encrypted_name(
|
.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,
|
||||||
&claims.sub,
|
&claims.sub,
|
||||||
&req.name_data,
|
&req.name_data,
|
||||||
&req.name_iv,
|
&req.name_iv,
|
||||||
&req.name_auth_tag,
|
&req.name_auth_tag,
|
||||||
|
&req.kind,
|
||||||
|
&req.relationship,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|
@ -140,3 +258,29 @@ pub async fn update_my_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" })),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
708
backend/src/handlers/profile_share.rs
Normal file
708
backend/src/handlers/profile_share.rs
Normal file
|
|
@ -0,0 +1,708 @@
|
||||||
|
//! 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,
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
@ -1,453 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -39,7 +39,7 @@ pub struct UpdateProfileRequest {
|
||||||
pub username: Option<String>,
|
pub username: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_profile(
|
pub async fn get_account(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(claims): Extension<Claims>,
|
Extension(claims): Extension<Claims>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
|
@ -79,7 +79,7 @@ pub async fn get_profile(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_profile(
|
pub async fn update_account(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(claims): Extension<Claims>,
|
Extension(claims): Extension<Claims>,
|
||||||
Json(req): Json<UpdateProfileRequest>,
|
Json(req): Json<UpdateProfileRequest>,
|
||||||
|
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -126,16 +126,21 @@ impl AppointmentRepository {
|
||||||
Ok(appointment)
|
Ok(appointment)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List a user's appointments, optionally filtered by top-level `status`.
|
/// List a user's appointments, optionally filtered by top-level `status`
|
||||||
|
/// and/or `profileId`.
|
||||||
pub async fn find_by_user_filtered(
|
pub async fn find_by_user_filtered(
|
||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
status: Option<&str>,
|
status: Option<&str>,
|
||||||
|
profile_id: Option<&str>,
|
||||||
) -> Result<Vec<Appointment>, Box<dyn std::error::Error>> {
|
) -> Result<Vec<Appointment>, Box<dyn std::error::Error>> {
|
||||||
let mut filter = doc! { "userId": user_id };
|
let mut filter = doc! { "userId": user_id };
|
||||||
if let Some(s) = status {
|
if let Some(s) = status {
|
||||||
filter.insert("status", s);
|
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 cursor = self.collection.find(filter, None).await?;
|
||||||
let mut appointments = Vec::new();
|
let mut appointments = Vec::new();
|
||||||
while let Some(appt) = cursor.next().await {
|
while let Some(appt) = cursor.next().await {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ pub struct HealthStatistic {
|
||||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||||
pub id: Option<ObjectId>,
|
pub id: Option<ObjectId>,
|
||||||
pub user_id: String,
|
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).
|
/// Opaque client-encrypted blob (value, unit, type, notes inside).
|
||||||
#[serde(rename = "encryptedData")]
|
#[serde(rename = "encryptedData")]
|
||||||
pub encrypted_data: EncryptedFieldWire,
|
pub encrypted_data: EncryptedFieldWire,
|
||||||
|
|
@ -27,6 +30,7 @@ pub struct HealthStatistic {
|
||||||
pub struct HealthStatResponse {
|
pub struct HealthStatResponse {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
|
pub profile_id: String,
|
||||||
pub encrypted_data: EncryptedFieldWire,
|
pub encrypted_data: EncryptedFieldWire,
|
||||||
pub recorded_at: String,
|
pub recorded_at: String,
|
||||||
}
|
}
|
||||||
|
|
@ -36,6 +40,7 @@ impl From<HealthStatistic> for HealthStatResponse {
|
||||||
HealthStatResponse {
|
HealthStatResponse {
|
||||||
id: s.id.map(|o| o.to_hex()).unwrap_or_default(),
|
id: s.id.map(|o| o.to_hex()).unwrap_or_default(),
|
||||||
user_id: s.user_id,
|
user_id: s.user_id,
|
||||||
|
profile_id: s.profile_id,
|
||||||
encrypted_data: s.encrypted_data,
|
encrypted_data: s.encrypted_data,
|
||||||
recorded_at: s.recorded_at,
|
recorded_at: s.recorded_at,
|
||||||
}
|
}
|
||||||
|
|
@ -70,6 +75,21 @@ impl HealthStatisticsRepository {
|
||||||
cursor.try_collect().await
|
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> {
|
pub async fn find_by_id(&self, id: &ObjectId) -> Result<Option<HealthStatistic>, MongoError> {
|
||||||
let filter = doc! { "_id": id };
|
let filter = doc! { "_id": id };
|
||||||
self.collection.find_one(filter, None).await
|
self.collection.find_one(filter, None).await
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,8 @@ pub mod health_stats;
|
||||||
pub mod interactions;
|
pub mod interactions;
|
||||||
pub mod lab_result;
|
pub mod lab_result;
|
||||||
pub mod medication;
|
pub mod medication;
|
||||||
pub mod permission;
|
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
|
pub mod profile_share;
|
||||||
pub mod refresh_token;
|
pub mod refresh_token;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod share;
|
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
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>;
|
|
||||||
|
|
@ -1,35 +1,76 @@
|
||||||
|
use futures::stream::StreamExt;
|
||||||
use mongodb::{
|
use mongodb::{
|
||||||
bson::{doc, oid::ObjectId, DateTime},
|
bson::{doc, oid::ObjectId, DateTime},
|
||||||
Collection,
|
Collection,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Profile {
|
pub struct Profile {
|
||||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||||
pub id: Option<ObjectId>,
|
pub id: Option<ObjectId>,
|
||||||
#[serde(rename = "profileId")]
|
#[serde(rename = "profileId")]
|
||||||
pub profile_id: String,
|
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")]
|
#[serde(rename = "userId")]
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
#[serde(rename = "familyId")]
|
#[serde(rename = "familyId")]
|
||||||
pub family_id: Option<String>,
|
pub family_id: Option<String>,
|
||||||
|
/// Display name — opaque, encrypted under the profile DEK.
|
||||||
#[serde(rename = "name")]
|
#[serde(rename = "name")]
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(rename = "nameIv")]
|
#[serde(rename = "nameIv")]
|
||||||
pub name_iv: String,
|
pub name_iv: String,
|
||||||
#[serde(rename = "nameAuthTag")]
|
#[serde(rename = "nameAuthTag")]
|
||||||
pub name_auth_tag: String,
|
pub name_auth_tag: String,
|
||||||
#[serde(rename = "role")]
|
/// "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")]
|
||||||
pub role: String,
|
pub role: String,
|
||||||
#[serde(rename = "permissions")]
|
#[serde(rename = "permissions", default)]
|
||||||
pub permissions: Vec<String>,
|
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")]
|
#[serde(rename = "createdAt")]
|
||||||
pub created_at: DateTime,
|
pub created_at: DateTime,
|
||||||
#[serde(rename = "updatedAt")]
|
#[serde(rename = "updatedAt")]
|
||||||
pub updated_at: DateTime,
|
pub updated_at: DateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_kind() -> String {
|
||||||
|
"human".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_role() -> String {
|
||||||
|
"patient".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ProfileRepository {
|
pub struct ProfileRepository {
|
||||||
collection: Collection<Profile>,
|
collection: Collection<Profile>,
|
||||||
}
|
}
|
||||||
|
|
@ -44,6 +85,7 @@ impl ProfileRepository {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Look up a profile by its application-level profile id.
|
||||||
pub async fn find_by_profile_id(
|
pub async fn find_by_profile_id(
|
||||||
&self,
|
&self,
|
||||||
profile_id: &str,
|
profile_id: &str,
|
||||||
|
|
@ -53,32 +95,102 @@ impl ProfileRepository {
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Look up a profile by its owning user id.
|
/// Look up a profile by id AND owner — used for ownership-scoped access.
|
||||||
pub async fn find_by_user_id(&self, user_id: &str) -> mongodb::error::Result<Option<Profile>> {
|
/// 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>> {
|
||||||
self.collection
|
self.collection
|
||||||
.find_one(doc! { "userId": user_id }, None)
|
.find_one(
|
||||||
|
doc! { "profileId": profile_id, "ownerAccountId": owner },
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the profile's display name (opaque client-encrypted blob).
|
/// All profiles owned by an account.
|
||||||
pub async fn update_encrypted_name(
|
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(
|
||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
profile_id: &str,
|
||||||
|
owner: &str,
|
||||||
name_data: &str,
|
name_data: &str,
|
||||||
name_iv: &str,
|
name_iv: &str,
|
||||||
name_auth_tag: &str,
|
name_auth_tag: &str,
|
||||||
|
kind: &str,
|
||||||
|
relationship: &str,
|
||||||
) -> mongodb::error::Result<Option<Profile>> {
|
) -> mongodb::error::Result<Option<Profile>> {
|
||||||
self.collection
|
self.collection
|
||||||
.find_one_and_update(
|
.find_one_and_update(
|
||||||
doc! { "userId": user_id },
|
doc! { "profileId": profile_id, "ownerAccountId": owner },
|
||||||
doc! { "$set": {
|
doc! { "$set": {
|
||||||
"name": name_data,
|
"name": name_data,
|
||||||
"nameIv": name_iv,
|
"nameIv": name_iv,
|
||||||
"nameAuthTag": name_auth_tag,
|
"nameAuthTag": name_auth_tag,
|
||||||
|
"kind": kind,
|
||||||
|
"relationship": relationship,
|
||||||
"updatedAt": DateTime::now()
|
"updatedAt": DateTime::now()
|
||||||
}},
|
}},
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
222
backend/src/models/profile_share.rs
Normal file
222
backend/src/models/profile_share.rs
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -37,6 +37,21 @@ pub struct User {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub recovery_wrapped_dek_iv: Option<String>,
|
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
|
/// Token version for invalidating all tokens on password change
|
||||||
pub token_version: i32,
|
pub token_version: i32,
|
||||||
|
|
||||||
|
|
@ -93,6 +108,9 @@ impl User {
|
||||||
wrapped_dek_iv: None,
|
wrapped_dek_iv: None,
|
||||||
recovery_wrapped_dek: None,
|
recovery_wrapped_dek: None,
|
||||||
recovery_wrapped_dek_iv: None,
|
recovery_wrapped_dek_iv: None,
|
||||||
|
identity_public_key: None,
|
||||||
|
identity_private_key_wrapped: None,
|
||||||
|
identity_private_key_wrapped_iv: None,
|
||||||
token_version: 0,
|
token_version: 0,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
last_active: now,
|
last_active: now,
|
||||||
|
|
|
||||||
|
|
@ -287,6 +287,80 @@ async fn password_change_invalidates_existing_tokens() {
|
||||||
common::drop_test_db(&db_name).await;
|
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 ----
|
// ---- helpers ----
|
||||||
|
|
||||||
fn unique_email() -> String {
|
fn unique_email() -> String {
|
||||||
|
|
|
||||||
258
backend/tests/ownership_tests.rs
Normal file
258
backend/tests/ownership_tests.rs
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
//! 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;
|
||||||
|
}
|
||||||
255
backend/tests/profile_tests.rs
Normal file
255
backend/tests/profile_tests.rs
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
//! 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;
|
||||||
|
}
|
||||||
382
backend/tests/rekey_tests.rs
Normal file
382
backend/tests/rekey_tests.rs
Normal file
|
|
@ -0,0 +1,382 @@
|
||||||
|
//! 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(®ister_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;
|
||||||
|
}
|
||||||
456
backend/tests/share_tests.rs
Normal file
456
backend/tests/share_tests.rs
Normal file
|
|
@ -0,0 +1,456 @@
|
||||||
|
//! 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;
|
||||||
|
}
|
||||||
|
|
@ -179,6 +179,7 @@ async fn health_stat_stored_as_ciphertext() {
|
||||||
"POST",
|
"POST",
|
||||||
"/api/health-stats",
|
"/api/health-stats",
|
||||||
Some(json!({
|
Some(json!({
|
||||||
|
"profile_id": "default",
|
||||||
"encrypted_data": { "data": opaque_data, "iv": "aXY=" },
|
"encrypted_data": { "data": opaque_data, "iv": "aXY=" },
|
||||||
"recorded_at": "2026-07-01T10:00:00Z",
|
"recorded_at": "2026-07-01T10:00:00Z",
|
||||||
})),
|
})),
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,15 @@ the documentation reconciliation. They are historical context, not current specs
|
||||||
|--------|-------|
|
|--------|-------|
|
||||||
| [tech-stack-decision.md](./tech-stack-decision.md) | Master stack choice: Rust/Axum backend, React frontend, MongoDB, JWT |
|
| [tech-stack-decision.md](./tech-stack-decision.md) | Master stack choice: Rust/Axum backend, React frontend, MongoDB, JWT |
|
||||||
| [mongodb-schema-decision.md](./mongodb-schema-decision.md) | Document model + at-rest encryption approach |
|
| [mongodb-schema-decision.md](./mongodb-schema-decision.md) | Document model + at-rest encryption approach |
|
||||||
| [jwt-authentication-decision.md](./jwt-authentication-decision.md) | JWT access/refresh tokens, recovery phrases |
|
| [jwt-authentication-decision.md](./jwt-authentication-decision.md) | JWT access/refresh tokens, `token_version` revocation, refresh rotation — *reconciled with code 2026-07-18* |
|
||||||
| [frontend-decision-summary.md](./frontend-decision-summary.md) | React (web) + React Native (mobile, future) split |
|
| [frontend-decision-summary.md](./frontend-decision-summary.md) | React (web) + React Native (mobile, future) split |
|
||||||
| [state-management-decision.md](./state-management-decision.md) | Client state — *superseded*: decision was Redux Toolkit, **actual code uses Zustand** |
|
| [state-management-decision.md](./state-management-decision.md) | Client state — *superseded*: decision was Redux Toolkit, **actual code uses Zustand** |
|
||||||
| [monorepo-structure.md](./monorepo-structure.md) | Repository layout (`backend/`, `web/`, `mobile/`, `docs/`) |
|
| [monorepo-structure.md](./monorepo-structure.md) | Repository layout (`backend/`, `web/`, `mobile/`, `docs/`) |
|
||||||
| [backend-deployment-constraints.md](./backend-deployment-constraints.md) | Deployment requirements (Solaria, Docker) |
|
| [backend-deployment-constraints.md](./backend-deployment-constraints.md) | Deployment requirements (Solaria, Docker) |
|
||||||
| [mobile-health-frameworks-data.md](./mobile-health-frameworks-data.md) | HealthKit / Health Connect data-type reference (for future mobile work) |
|
| [mobile-health-frameworks-data.md](./mobile-health-frameworks-data.md) | HealthKit / Health Connect data-type reference (for future mobile work) |
|
||||||
| [android-health-connect-data-types.md](./android-health-connect-data-types.md) | Android Health Connect data types |
|
| [android-health-connect-data-types.md](./android-health-connect-data-types.md) | Android Health Connect data types |
|
||||||
| [zero-knowledge-encryption.md](./zero-knowledge-encryption.md) | Client-side zero-knowledge encryption (AES-256-GCM, double-PBKDF2, Phase 1) |
|
| [zero-knowledge-encryption.md](./zero-knowledge-encryption.md) | Client-side zero-knowledge encryption (AES-256-GCM, double-PBKDF2, wrapped-DEK recovery; Phase 1 + Phase 2 implemented) |
|
||||||
|
| [multi-person-sharing.md](./multi-person-sharing.md) | Per-profile DEK + X25519 envelope for family/caregiver sharing — *Decided, not yet implemented* (issue #3) |
|
||||||
|
|
||||||
> **Note**: Where a decision diverges from the implemented code (e.g. state
|
> **Note**: Where a decision diverges from the implemented code (e.g. state
|
||||||
> management), the code is the source of truth and the ADR is kept only as
|
> management), the code is the source of truth and the ADR is kept only as
|
||||||
|
|
|
||||||
|
|
@ -1,174 +1,185 @@
|
||||||
# JWT Authentication Decision Summary
|
# ADR: JWT Authentication
|
||||||
|
|
||||||
**Date**: 2026-02-14
|
**Status**: Implemented (current code is the source of truth)
|
||||||
**Decision**: **JWT with Refresh Tokens + Recovery Phrases**
|
**Date**: 2026-02-14 (original decision), 2026-07-18 (reconciled with code)
|
||||||
|
|
||||||
---
|
> This ADR was originally written during Phase 1 research and described several
|
||||||
|
> things that were never built (bcrypt, Redis, family-role permission claims in
|
||||||
|
> the JWT, the specific recovery-phrase flow). It has been rewritten to match
|
||||||
|
> the implementation. The original reasoning is preserved in the "History"
|
||||||
|
> section at the end. Where code and the old text disagree, **the code wins**.
|
||||||
|
|
||||||
## Authentication Strategy
|
## Context
|
||||||
|
|
||||||
### Primary: JWT (JSON Web Tokens)
|
Normogen authenticates accounts with email + password and authorizes API
|
||||||
|
requests with stateless JWTs. This sits *underneath* the zero-knowledge
|
||||||
|
encryption layer (see [`zero-knowledge-decryption.md`](./zero-knowledge-encryption.md)
|
||||||
|
and [`encryption.md`](../product/encryption.md)): authentication proves who you
|
||||||
|
are and lets you fetch your wrapped DEK; the client-side ZK layer then unlocks
|
||||||
|
your data. The auth system never sees plaintext health data.
|
||||||
|
|
||||||
**Why JWT?**
|
## Decision
|
||||||
- Stateless design scales to 1000+ concurrent connections
|
|
||||||
- Works perfectly with mobile apps (AsyncStorage)
|
|
||||||
- No server-side session storage needed
|
|
||||||
- Easy to scale Axum horizontally
|
|
||||||
|
|
||||||
### Token Types
|
### Token model
|
||||||
|
|
||||||
**Access Token** (15 minutes)
|
Two JWTs, both HS256-signed with a shared secret from config:
|
||||||
- Used for API requests
|
|
||||||
- Short-lived for security
|
|
||||||
- Contains: user_id, email, family_id, permissions
|
|
||||||
|
|
||||||
**Refresh Token** (30 days)
|
| Token | Lifetime | Claims | Purpose |
|
||||||
- Used to get new access tokens
|
|---|---|---|---|
|
||||||
- Long-lived for convenience
|
| Access | configurable (`JwtConfig.access_token_expiry_minutes`, default **15 min**) | `{sub, exp, iat, user_id, email, token_version}` | Sent on every API request |
|
||||||
- Stored in MongoDB for revocation
|
| Refresh | configurable (`JwtConfig.refresh_token_expiry_days`, default **30 days**) | `{sub, exp, iat, jti, user_id, token_version}` | Exchange for a new access token |
|
||||||
- Rotated on every refresh
|
|
||||||
|
|
||||||
---
|
> Reference: `backend/src/auth/jwt.rs` (`Claims`, `RefreshClaims`, `JwtService`).
|
||||||
|
|
||||||
## Token Revocation Strategies
|
The lifetimes are **config-driven**, not hardcoded — the original ADR's "15 min
|
||||||
|
/ 30 days" are the defaults, not constants.
|
||||||
|
|
||||||
### 1. Refresh Token Blacklist (Recommended) ⭐
|
### Claims (actual, as implemented)
|
||||||
- Store refresh tokens in MongoDB
|
|
||||||
- Mark as revoked on logout
|
|
||||||
- Check on every refresh
|
|
||||||
|
|
||||||
### 2. Token Versioning
|
The earlier ADR listed richer claims (`family_id`, `permissions`, `token_type`).
|
||||||
- Include version in JWT claims
|
**These are not in the shipped access token.** The real `Claims` struct is:
|
||||||
- Increment on password change
|
|
||||||
- Invalidate all tokens when version changes
|
|
||||||
|
|
||||||
### 3. Access Token Blacklist (Optional)
|
```rust
|
||||||
- Store revoked access tokens in Redis
|
pub struct Claims { // access token
|
||||||
- For immediate revocation
|
pub sub: String, // = user_id (ObjectId hex)
|
||||||
- Auto-expires with TTL
|
pub exp: usize,
|
||||||
|
pub iat: usize,
|
||||||
|
pub user_id: String,
|
||||||
|
pub email: String,
|
||||||
|
pub token_version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
---
|
pub struct RefreshClaims { // refresh token
|
||||||
|
pub sub: String,
|
||||||
## Refresh Token Pattern
|
pub exp: usize,
|
||||||
|
pub iat: usize,
|
||||||
### Token Rotation (Security Best Practice) ⭐
|
pub jti: String, // unique per token (see below)
|
||||||
|
pub user_id: String,
|
||||||
**Flow**:
|
pub token_version: i32,
|
||||||
1. Client sends refresh_token
|
|
||||||
2. Server verifies refresh_token (not revoked, not expired)
|
|
||||||
3. Server generates new access_token
|
|
||||||
4. Server generates new refresh_token
|
|
||||||
5. Server revokes old refresh_token
|
|
||||||
6. Server returns new tokens
|
|
||||||
|
|
||||||
**Why?** Prevents reuse of stolen refresh tokens
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Zero-Knowledge Password Recovery
|
|
||||||
|
|
||||||
### Recovery Phrases (from encryption.md)
|
|
||||||
|
|
||||||
**Registration**:
|
|
||||||
1. Client generates recovery phrase (random 32 bytes)
|
|
||||||
2. Client encrypts recovery phrase with password
|
|
||||||
3. Client sends: email, password hash, encrypted recovery phrase
|
|
||||||
4. Server stores: email, password hash, encrypted recovery phrase
|
|
||||||
|
|
||||||
**Password Recovery**:
|
|
||||||
1. User requests recovery (enters email)
|
|
||||||
2. Server returns: encrypted recovery phrase
|
|
||||||
3. Client decrypts with recovery key (user enters manually)
|
|
||||||
4. User enters new password
|
|
||||||
5. Client re-encrypts recovery phrase with new password
|
|
||||||
6. Client sends: new password hash, re-encrypted recovery phrase
|
|
||||||
7. Server updates: password hash, encrypted recovery phrase, token_version + 1
|
|
||||||
8. All existing tokens invalidated (version mismatch)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Family Member Access Control
|
|
||||||
|
|
||||||
### Permissions in JWT
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// JWT permissions based on family role
|
|
||||||
{
|
|
||||||
"parent": [
|
|
||||||
"read:own_data",
|
|
||||||
"write:own_data",
|
|
||||||
"read:family_data",
|
|
||||||
"write:family_data",
|
|
||||||
"manage:family_members",
|
|
||||||
"delete:data"
|
|
||||||
],
|
|
||||||
"child": [
|
|
||||||
"read:own_data",
|
|
||||||
"write:own_data"
|
|
||||||
],
|
|
||||||
"elderly": [
|
|
||||||
"read:own_data",
|
|
||||||
"write:own_data",
|
|
||||||
"read:family_data"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Permission Middleware
|
Note what is **absent** and why:
|
||||||
|
|
||||||
- Check permissions on protected routes
|
- **No `family_id` or `permissions` in the token.** Family/permission
|
||||||
- Return 403 Forbidden if insufficient permissions
|
enforcement is not implemented at the auth layer (see issue #3 — multi-person
|
||||||
- Works with JWT claims
|
sharing is an open design problem). Putting unenforced claims in the token
|
||||||
|
would imply protection that doesn't exist.
|
||||||
|
- (Historical: an orphaned `backend/src/auth/claims.rs` once defined a
|
||||||
|
duplicate `AccessClaims` struct with `family_id`/`permissions`/`jti`. It
|
||||||
|
was dead code — never declared as a module, never imported — and has been
|
||||||
|
removed; see issue #5.)
|
||||||
|
- **No `token_type` discriminator.** Access and refresh claims are different
|
||||||
|
structs, so a token can only decode as one or the other — the field is
|
||||||
|
redundant and was dropped.
|
||||||
|
|
||||||
---
|
### Refresh tokens: persistence, rotation, reuse detection
|
||||||
|
|
||||||
## Technology Stack
|
- Refresh tokens are **stored in MongoDB** (`refresh_tokens` collection) — not
|
||||||
|
Redis (the original ADR listed Redis as an option; it is not used).
|
||||||
|
- The `jti` claim makes every refresh token unique. Without it, two refresh
|
||||||
|
tokens issued in the same second for the same user would be byte-identical,
|
||||||
|
which breaks rotation and reuse detection. (Covered by the
|
||||||
|
`refresh_tokens_are_unique_even_in_same_second` test in `jwt.rs`.)
|
||||||
|
- On refresh: validate signature + expiry, confirm the token exists and is not
|
||||||
|
revoked in the repository, mint a new access + refresh pair, revoke the old
|
||||||
|
refresh token (rotation).
|
||||||
|
|
||||||
### Backend (Axum)
|
### Token revocation: versioning + session management
|
||||||
- jsonwebtoken 9.x (JWT crate)
|
|
||||||
- bcrypt 0.15 (password hashing)
|
|
||||||
- mongodb 3.0 (refresh token storage)
|
|
||||||
- redis (optional, for access token blacklist)
|
|
||||||
|
|
||||||
### Client (React Native + React)
|
Two mechanisms, both implemented:
|
||||||
- AsyncStorage (token storage)
|
|
||||||
- axios (API client with JWT interceptor)
|
|
||||||
- PBKDF2 (password derivation)
|
|
||||||
- AES-256-GCM (data encryption)
|
|
||||||
|
|
||||||
---
|
1. **`token_version` (global kill-switch).** Stored on the `User` document,
|
||||||
|
incremented on password change and password recovery. The JWT middleware
|
||||||
|
(`middleware/auth.rs`) rejects any access token whose `token_version` is
|
||||||
|
stale against the user's current value. A short-lived `TokenVersionCache`
|
||||||
|
avoids hitting Mongo on every request.
|
||||||
|
2. **Session management.** Refresh tokens are tracked as sessions
|
||||||
|
(`GET /api/sessions`, `DELETE /api/sessions/:id`, `DELETE /api/sessions/all`)
|
||||||
|
so a user can list and revoke individual logins.
|
||||||
|
|
||||||
## Implementation Timeline
|
### Password handling
|
||||||
|
|
||||||
- **Week 1**: Basic JWT (login, register, middleware)
|
- **PBKDF2** (not bcrypt — the original ADR mentioned bcrypt), via the `pbkdf2`
|
||||||
- **Week 1-2**: Refresh tokens (storage, rotation)
|
crate with a per-password random salt. (`backend/src/auth/password.rs`)
|
||||||
- **Week 2**: Token revocation (blacklist, versioning)
|
- Under the ZK model, what the server hashes is the **auth secret** — a
|
||||||
- **Week 2-3**: Password recovery (recovery phrases)
|
base64'd PBKDF2 derivation of the user's password produced client-side — not
|
||||||
- **Week 3**: Family access control (permissions)
|
the raw password. The raw password never leaves the browser. See
|
||||||
- **Week 3-4**: Security hardening (rate limiting, HTTPS)
|
[`encryption.md`](../product/encryption.md) §2.
|
||||||
|
|
||||||
**Total**: 3-4 weeks
|
### Password recovery
|
||||||
|
|
||||||
---
|
Recovery is handled by the **zero-knowledge wrapped-DEK** mechanism, *not* by
|
||||||
|
the "client encrypts the recovery phrase with the password" scheme the original
|
||||||
|
ADR described. The client derives a recovery KEK from the recovery phrase,
|
||||||
|
unwraps the DEK from the recovery-wrapped form, and re-wraps it under the new
|
||||||
|
password. See [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md)
|
||||||
|
§"Phase 2: Wrapped-DEK Recovery". The server bumps `token_version` on recovery,
|
||||||
|
invalidating all prior tokens.
|
||||||
|
|
||||||
## Next Steps
|
### What protects what
|
||||||
|
|
||||||
1. Implement basic JWT service in Axum
|
Auth + the ciphertext store are defended server-side; plaintext health data is
|
||||||
2. Create MongoDB schema for users and refresh tokens
|
never on the server to protect:
|
||||||
3. Implement login/register/refresh/logout handlers
|
|
||||||
4. Create JWT middleware for protected routes
|
|
||||||
5. Implement token revocation (blacklist + versioning)
|
|
||||||
6. Integrate password recovery (from encryption.md)
|
|
||||||
7. Implement family access control (permissions)
|
|
||||||
8. Test entire authentication flow
|
|
||||||
9. Create client-side authentication (React Native + React)
|
|
||||||
|
|
||||||
---
|
| Layer | Mechanism |
|
||||||
|
|---|---|
|
||||||
|
| Password / auth secret | PBKDF2 + per-password salt |
|
||||||
|
| Tokens | HS256 signature, short access TTL, refresh rotation + `jti` reuse detection |
|
||||||
|
| Revocation | `token_version` (global) + session list (per-login) |
|
||||||
|
| Brute force | Account lockout (5 attempts → exponential backoff) + rate limiting |
|
||||||
|
| Forensics | Audit logging (auth attempts, authz checks) |
|
||||||
|
|
||||||
|
> Reference: `backend/src/auth/`, `backend/src/security/`, `backend/src/middleware/`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- Stateless access tokens scale horizontally (no DB lookup per request except
|
||||||
|
the cached version check).
|
||||||
|
- `token_version` gives instant global revocation on password change/recovery
|
||||||
|
without maintaining a blocklist of outstanding tokens.
|
||||||
|
- Refresh rotation + `jti` detects token theft: a rotated-away token being
|
||||||
|
presented again signals reuse.
|
||||||
|
|
||||||
|
### Negative / costs
|
||||||
|
|
||||||
|
- `token_version` revocation is **coarse** — it kills *all* of a user's
|
||||||
|
sessions at once. Per-session revocation is only available via the session
|
||||||
|
list (refresh-token deletion).
|
||||||
|
- HS256 with a shared secret means the secret is sensitive infrastructure; a
|
||||||
|
key rotation requires invalidating all tokens (acceptable for the deployment
|
||||||
|
model, but worth noting vs. asymmetric RS/ES keys).
|
||||||
|
|
||||||
|
## Open items
|
||||||
|
|
||||||
|
- ~~Remove `backend/src/auth/claims.rs` (dead `AccessClaims`) or wire it up
|
||||||
|
intentionally.~~ **Done** — the orphaned file has been deleted (issue #5,
|
||||||
|
PR #<to fill after open>). The live `Claims` / `RefreshClaims` structs in
|
||||||
|
`backend/src/auth/jwt.rs` are the only ones.
|
||||||
|
- When issue #3 (multi-person sharing) is decided, revisit whether family /
|
||||||
|
share authorization belongs in the JWT or is enforced per-request against a
|
||||||
|
shares collection.
|
||||||
|
|
||||||
|
## History (original 2026-02-14 decision, superseded where noted)
|
||||||
|
|
||||||
|
The original ADR proposed: JWT with refresh tokens; bcrypt; optional Redis for
|
||||||
|
access-token blacklists; recovery by client-encrypting the recovery phrase with
|
||||||
|
the password; and `family_id`/`permissions` claims with parent/child/elderly
|
||||||
|
role matrices. Of these, only **JWT with refresh tokens + rotation** was built
|
||||||
|
as described. Password hashing uses PBKDF2 (not bcrypt), there is no Redis,
|
||||||
|
recovery uses the wrapped-DEK scheme (not phrase-encryption), and the family/
|
||||||
|
permission claims were never wired into the real `Claims`. This section is kept
|
||||||
|
as a record of the original reasoning; the sections above are authoritative.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- [Comprehensive JWT Research](./2026-02-14-jwt-authentication-research.md)
|
- Code: `backend/src/auth/jwt.rs`, `backend/src/auth/password.rs`,
|
||||||
- [Normogen Encryption Guide](../encryption.md)
|
`backend/src/middleware/auth.rs`, `backend/src/auth/token_version_cache.rs`,
|
||||||
- [JWT RFC 7519](https://tools.ietf.org/html/rfc7519)
|
`backend/src/models/refresh_token.rs`, `backend/src/models/session.rs`
|
||||||
- [Axum JWT Guide](https://docs.rs/axum/latest/axum/)
|
- Related ADRs: [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md),
|
||||||
- [OWASP JWT Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html)
|
[`multi-person-sharing.md`](./multi-person-sharing.md) (draft)
|
||||||
|
- Product doc: [`encryption.md`](../product/encryption.md)
|
||||||
|
- Related issues: [#3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3),
|
||||||
|
[#4](https://gitea.soliverez.com.ar/alvaro/normogen/issues/4)
|
||||||
|
|
|
||||||
336
docs/adr/multi-person-sharing.md
Normal file
336
docs/adr/multi-person-sharing.md
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
# ADR: Multi-Person Sharing Under Zero-Knowledge
|
||||||
|
|
||||||
|
**Status**: Decided (not yet implemented)
|
||||||
|
**Date**: 2026-07-18
|
||||||
|
**Discussion**: [issue #3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3)
|
||||||
|
**Related**: [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md) (the single-user ZK model this extends)
|
||||||
|
|
||||||
|
> Records the design decision and its rationale ahead of implementation,
|
||||||
|
> following the pattern of `zero-knowledge-encryption.md`. The five original
|
||||||
|
> open questions were resolved on 2026-07-18 (see "Open questions" section).
|
||||||
|
> Two deferred items remain (pubkey verification mechanism; admin re-key
|
||||||
|
> quorum) — neither blocks implementation. All crypto primitives named here
|
||||||
|
> are available in the browser's Web Crypto API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Normogen's core product vision (see
|
||||||
|
[`docs/product/PERSONA_AND_FAMILY_MANAGEMENT.md`](../product/PERSONA_AND_FAMILY_MANAGEMENT.md))
|
||||||
|
is multi-person health data: a parent manages a child's medications and shares
|
||||||
|
them with a spouse or caregiver. The existing zero-knowledge model
|
||||||
|
(`zero-knowledge-encryption.md`) is single-user: **all of a user's data is
|
||||||
|
encrypted under one DEK that only that user can unwrap.** There is no mechanism
|
||||||
|
for a second user to decrypt any of it. As called out in issue #3, the current
|
||||||
|
`Share` model + `/api/shares` routes store ACL-style permission records but
|
||||||
|
cannot grant decryption access to another account — every medication blob is
|
||||||
|
under the owner's DEK, and the recipient has a different one.
|
||||||
|
|
||||||
|
The product requirements that constrain this design (confirmed in issue #3):
|
||||||
|
|
||||||
|
1. **Parents and spouses have separate logins**, and may belong to *different*
|
||||||
|
families. Divorced parents with their own families must be able to share the
|
||||||
|
profile of their common child **and nothing else**.
|
||||||
|
2. **Recipients always have Normogen accounts.** (One-way document dumps to
|
||||||
|
non-accounts — e.g. a physician via an encrypted PDF — is a separate,
|
||||||
|
simpler feature, out of scope here.)
|
||||||
|
3. **Revocation is a hard requirement.** Cutting off a caregiver must take
|
||||||
|
effect.
|
||||||
|
4. **The server operator is fully untrusted.** Key exchange cannot rely on the
|
||||||
|
server brokering symmetric secrets.
|
||||||
|
|
||||||
|
Additional use cases from issue #3 that the model must accommodate:
|
||||||
|
|
||||||
|
- **Profile graduation.** A child profile eventually becomes an independent
|
||||||
|
user with their own login, who may then revoke the parents' access.
|
||||||
|
- **Pets.** A profile may be an animal (medications, labs, etc.), not a human.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Implement **per-profile DEKs with an X25519 asymmetric envelope** for sharing,
|
||||||
|
keeping the server a fully blind store. Concretely:
|
||||||
|
|
||||||
|
### 1. Key hierarchy
|
||||||
|
|
||||||
|
```
|
||||||
|
password KEK ──wraps──▶ account DEK ──wraps each──▶ profile DEK ──encrypts──▶ profile data
|
||||||
|
│ (one per profile)
|
||||||
|
└──also wraps──▶ account X25519 private key
|
||||||
|
(account X25519 public key stored plaintext)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Account DEK**: random AES-256-GCM key, wrapped under the password KEK
|
||||||
|
(unchanged from today). At login the client unwraps it and holds it in
|
||||||
|
memory.
|
||||||
|
- **Profile DEK**: a *new* random AES-256-GCM key **per profile**. All data
|
||||||
|
belonging to a profile (its medications, appointments, etc.) is encrypted
|
||||||
|
under the profile DEK, not the account DEK. Each profile DEK is wrapped under
|
||||||
|
the account DEK and stored on the server, indexed by `profileId`.
|
||||||
|
- **Account X25519 keypair**: generated at registration. The **private** half
|
||||||
|
is wrapped under the account DEK (so it unlocks at login); the **public**
|
||||||
|
half is stored in plaintext — public keys are not secret.
|
||||||
|
|
||||||
|
This adds one tier to today's flat `account DEK → data`. Rationale in
|
||||||
|
Consequences (§3-tier vs flat).
|
||||||
|
|
||||||
|
### 2. Ownership and access
|
||||||
|
|
||||||
|
- Each **profile** has exactly one **owning account** at a time. The owner
|
||||||
|
holds the profile DEK wrapped to their account DEK (the *owner share*).
|
||||||
|
- **Shared access** = the profile DEK wrapped to another account's X25519
|
||||||
|
public key. Stored on the server as a *recipient share*, indexed by
|
||||||
|
`(profileId, recipientUserId)`.
|
||||||
|
- To **read** a shared profile, the recipient's client (at login) unwraps their
|
||||||
|
X25519 private key, ECDH-derives a wrapping key against the share's ephemeral
|
||||||
|
public key, and unwraps the profile DEK.
|
||||||
|
|
||||||
|
This is the standard asymmetric-envelope pattern (cf. Signal, `age`,
|
||||||
|
Tarsnap). The server only ever stores wrapped blobs and public keys.
|
||||||
|
|
||||||
|
### 3. Sharing flow (owner → recipient)
|
||||||
|
|
||||||
|
Client-side only, server untrusted:
|
||||||
|
|
||||||
|
1. Owner's client looks up the **recipient's X25519 public key**
|
||||||
|
(`GET /api/users/:id/public-key`, new).
|
||||||
|
2. Owner's client unwraps the target profile's DEK (via their account DEK).
|
||||||
|
3. Generate an **ephemeral X25519 keypair** for this share.
|
||||||
|
4. ECDH(ephemeral priv, recipient pub) → AES-GCM **wrapping key**.
|
||||||
|
5. Wrap the profile DEK under that wrapping key → `{ciphertext, iv, ephemeralPubKey}`.
|
||||||
|
6. `POST /api/profiles/:id/shares` with `{recipientUserId, wrappedDek, ephemeralPubKey, permissions, expiresAt?}`.
|
||||||
|
7. Server stores the share record. It cannot decrypt it.
|
||||||
|
|
||||||
|
### 4. Revocation
|
||||||
|
|
||||||
|
Two layers, in order of strength:
|
||||||
|
|
||||||
|
- **Soft revoke** (cheap): delete or deactivate the recipient share record. The
|
||||||
|
recipient's client no longer receives the wrapped DEK from the server, so it
|
||||||
|
cannot decrypt **new reads**. Caveat: a malicious client that cached the
|
||||||
|
profile DEK before revocation keeps decrypting data it already fetched.
|
||||||
|
- **Hard revoke / re-key** (strong): owner generates a **new profile DEK**,
|
||||||
|
re-encrypts all of the profile's data under it, re-wraps to the owner share
|
||||||
|
and to all *still-valid* recipient shares. A revoked recipient's cached old
|
||||||
|
DEK now fails to decrypt any future fetch. Required for the "kid revokes
|
||||||
|
parent" graduation case where you must assume the parent may have cached
|
||||||
|
data.
|
||||||
|
|
||||||
|
Hard revocation is the answer to requirement (3). Re-keying is expensive
|
||||||
|
(linear in profile data size) so it is opt-in / triggered explicitly, not the
|
||||||
|
default for every revoke.
|
||||||
|
|
||||||
|
### 5. Profile model changes
|
||||||
|
|
||||||
|
To support pets and graduation, the current `role: "patient"` string becomes:
|
||||||
|
|
||||||
|
```
|
||||||
|
kind: "human" | "pet" // distinguishes people from animals
|
||||||
|
relationship: string // freeform: "self", "child", "spouse", "pet", ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Ownership-transfer (graduation) is **not new crypto** — it is the existing
|
||||||
|
share machinery plus an `owner_account_id` field swap:
|
||||||
|
|
||||||
|
1. New adult account (the graduated child) generates its X25519 keypair.
|
||||||
|
2. Old owner wraps the profile DEK to the new account's pubkey as a share, and
|
||||||
|
marks that share as the new **owner share**.
|
||||||
|
3. Old owner's access is downgraded to (or removed as) a normal recipient share.
|
||||||
|
4. The new owner can revoke any remaining parent shares via soft/hard revoke.
|
||||||
|
|
||||||
|
### 5b. Use case: elderly parent cared for by children
|
||||||
|
|
||||||
|
A key motivation for the profile/account split is that **profile ownership and
|
||||||
|
account login are independent.** The elderly-care scenario has three setups,
|
||||||
|
all covered:
|
||||||
|
|
||||||
|
- **Parent self-manages, shares to children.** Parent owns their own account
|
||||||
|
and `self` profile; creates recipient shares to each child. Standard
|
||||||
|
share-to-another-account flow. Any party can revoke.
|
||||||
|
- **Managing child holds a dedicated parent account.** A separate account
|
||||||
|
exists for the parent (the identity of record), but the managing child holds
|
||||||
|
or controls the password and shares the profile to siblings. Cleaner than
|
||||||
|
embedding the parent as a profile inside the child's account, and preserves
|
||||||
|
the "take over later" path: if the parent becomes able (or, conversely,
|
||||||
|
passes away), it's the same ownership machinery.
|
||||||
|
- **Parent modeled as a profile inside the managing child's account.** No
|
||||||
|
separate login for the parent; the child owns the profile and shares it.
|
||||||
|
Simplest, but the parent has no independent identity and cannot revoke
|
||||||
|
themselves. Recommend against this when the parent is or may become an
|
||||||
|
independent actor.
|
||||||
|
|
||||||
|
**Death/incapacity of the sole-account owner** is the one wrinkle that needs
|
||||||
|
an explicit decision: see "Permissions and the admin-share question" below.
|
||||||
|
|
||||||
|
### 5c. Permissions and the admin-share question
|
||||||
|
|
||||||
|
The `permissions` list on a recipient share (Open question 3) raises a
|
||||||
|
sub-decision: **does an `admin` permission let a recipient act as a quasi-owner
|
||||||
|
without the original owner?** Specifically: re-share the profile, re-key it
|
||||||
|
(hard revoke), and add/remove other shares.
|
||||||
|
|
||||||
|
This matters for two scenarios:
|
||||||
|
|
||||||
|
- **Graduation in reverse / death of the owner.** If the parent owns the
|
||||||
|
account and becomes incapacitated, no child can write, re-share, or revoke —
|
||||||
|
those require the owner's account DEK, which is gone with the password.
|
||||||
|
- **Operational delegation.** A primary caregiver wants to delegate admin
|
||||||
|
tasks without sharing their password.
|
||||||
|
|
||||||
|
**Decision: support an `admin` permission tier.** An admin share can re-share,
|
||||||
|
re-key, and manage other shares on a profile. This keeps the owner-DEK model
|
||||||
|
intact (the owner share is still special — it can *transfer* ownership, which
|
||||||
|
admin cannot) while handling the death/incapacity and delegation cases without
|
||||||
|
key escrow. Hard revocation (re-key) by an admin rotates the profile DEK and
|
||||||
|
re-wraps to all *still-valid* shares, same as owner-initiated re-key.
|
||||||
|
|
||||||
|
> ❓ **Open sub-item:** when an admin re-keys, do we require a quorum or a
|
||||||
|
> second admin's consent? For MVP, no — any admin can act alone, matching how
|
||||||
|
> ownership transfer works today. Revisit if multi-party control becomes a
|
||||||
|
> requirement.
|
||||||
|
|
||||||
|
### 6. What happens to the current `Share` model
|
||||||
|
|
||||||
|
The existing `backend/src/models/share.rs` is **resource-level ACL**
|
||||||
|
(`resource_type`, `resource_id`, `permissions`) and predates encryption. It is
|
||||||
|
incompatible with this design, which shares at the **profile** level via keys,
|
||||||
|
not at the resource level via permissions.
|
||||||
|
|
||||||
|
Per issue #3, profile-level sharing covers the family/caregiver need; the
|
||||||
|
"share one document" need is handled separately (physician PDF feature). So:
|
||||||
|
|
||||||
|
- The current `Share` model and `/api/shares` routes are **retained for now**
|
||||||
|
but treated as a placeholder (issue #3). They will be either removed or
|
||||||
|
repurposed; this ADR does not depend on them.
|
||||||
|
- The new recipient-share records live in a **new collection** (provisional
|
||||||
|
name `profile_shares`) keyed by `(profileId, recipientUserId)`. Storing
|
||||||
|
wrapped DEKs, not ACLs.
|
||||||
|
|
||||||
|
### 7. Server surface (new endpoints, indicative)
|
||||||
|
|
||||||
|
- `GET /api/users/:id/public-key` → recipient's X25519 public key (plaintext).
|
||||||
|
- `GET /api/profiles/me/dek` → the account-wrapped profile DEK (owner unlock).
|
||||||
|
- `POST /api/profiles` → create profile: client generates profile DEK, wraps to
|
||||||
|
account DEK, sends wrapped form. Returns `profileId`.
|
||||||
|
- `GET /api/profiles/:id/shares` → list recipient shares for a profile.
|
||||||
|
- `POST /api/profiles/:id/shares` → create a recipient share (wrapped DEK).
|
||||||
|
- `DELETE /api/profiles/:id/shares/:shareId` → soft revoke.
|
||||||
|
- `POST /api/profiles/:id/rekey` → hard revoke / rotate the profile DEK.
|
||||||
|
|
||||||
|
All endpoints store/return opaque blobs or public keys; none expose a DEK or a
|
||||||
|
private key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- **Satisfies all four product constraints.** Per-profile keys enable the
|
||||||
|
divorced-parents case; hard revoke enables the revocation requirement; the
|
||||||
|
X25519 envelope keeps the server untrusted.
|
||||||
|
- **Profiles become first-class subjects of care** — owners, shares, pets,
|
||||||
|
graduation all fall out naturally.
|
||||||
|
- **Server stays a blind store.** No new backend crypto; still no `aes`/crypto
|
||||||
|
crates in `Cargo.toml`.
|
||||||
|
- **Composition with existing recovery.** The account DEK is still recovered
|
||||||
|
via the existing password/recovery wrapped-DEK flow; profile DEKs are wrapped
|
||||||
|
to the account DEK, so recovering the account recovers all profiles.
|
||||||
|
|
||||||
|
### Negative / costs
|
||||||
|
|
||||||
|
- **Adds a key tier.** Three levels instead of one. Complexity in the client
|
||||||
|
crypto module (`crypto/keys.ts` will grow X25519 + wrap-to-recipient logic).
|
||||||
|
- **Migration.** Today all data is under one account DEK. Splitting into
|
||||||
|
per-profile DEKs requires reading, decrypting, re-encrypting, and re-uploading
|
||||||
|
every document. **There is no real user data yet** (per
|
||||||
|
`zero-knowledge-encryption.md`), so this is cheap **now** and expensive
|
||||||
|
later — strong argument for doing the re-key before launch.
|
||||||
|
- **Hard revocation is expensive.** Re-keying a profile is O(profile data
|
||||||
|
size). Mitigation: soft revoke by default, hard revoke only when the owner
|
||||||
|
explicitly requires it (e.g. graduation, suspected compromise).
|
||||||
|
- **Recipient must have an account** (constraint 2). No magic-link sharing in
|
||||||
|
this design — deferred to the physician-PDF feature.
|
||||||
|
|
||||||
|
### Security notes
|
||||||
|
|
||||||
|
- **Ephemeral keys per share** prevent cross-share correlation and give
|
||||||
|
forward secrecy within a share's lifetime (re-keying rotates the DEK).
|
||||||
|
- **ECDH + AES-GCM** is the Web Crypto idiom; X25519 keys are 32 bytes, shares
|
||||||
|
are small.
|
||||||
|
- **Public keys are not authenticated by the server.** The owner must trust
|
||||||
|
that `GET /users/:id/public-key` returns the right key. Since the server is
|
||||||
|
untrusted, this is an accepted risk: a malicious server could swap the key
|
||||||
|
and force a re-share, but cannot decrypt. ❓ Decide whether to add a key
|
||||||
|
fingerprint the recipient verifies out-of-band.
|
||||||
|
|
||||||
|
## Open questions (❓) — resolved 2026-07-18
|
||||||
|
|
||||||
|
1. **Key hierarchy depth.** ✅ **Decided: 3-tier** (account DEK → profile DEK →
|
||||||
|
data). Rationale: account DEK is recovered via the existing wrapped-DEK
|
||||||
|
recovery flow, so password change only re-wraps the account DEK (O(1)),
|
||||||
|
not every profile DEK. Profile DEKs are re-wrapped only on profile-level
|
||||||
|
events (share/revoke/rekey), which are rare.
|
||||||
|
|
||||||
|
2. **Public-key trust.** ✅ **Decided: accept key-swap as DoS-only for now.**
|
||||||
|
A malicious server can swap a recipient's public key and force a failed
|
||||||
|
share (DoS) but cannot decrypt. **Future enhancement (not blocking):** add
|
||||||
|
a mechanism for out-of-band pubkey verification. Candidate approaches to
|
||||||
|
evaluate when we get there:
|
||||||
|
- **Key fingerprint / safety number.** Display a short hash of a user's
|
||||||
|
pubkey that two parties can compare out-of-band (cf. Signal safety
|
||||||
|
numbers). Cheap, requires user action.
|
||||||
|
- **Pubkey in recovery phrase / account export.** Derive or include the
|
||||||
|
pubkey fingerprint in something the user already verifies.
|
||||||
|
- **Web-of-trust / TOFU.** First-seen pubkey is pinned client-side; a change
|
||||||
|
triggers a user-visible warning.
|
||||||
|
These are additive — none changes the core envelope design, so they can
|
||||||
|
ship later without a migration.
|
||||||
|
|
||||||
|
3. **Share record schema.** ✅ **Decided: `profile_shares` collection, schema
|
||||||
|
as proposed, permissions included.** Fields:
|
||||||
|
`{profileId, recipientUserId, ephemeralPubKey, wrappedDek, iv, permissions,
|
||||||
|
expiresAt, active, createdAt}`. `permissions` is a list (e.g.
|
||||||
|
`["read"]`, `["read","write"]`, `["read","write","admin"]`); the server
|
||||||
|
enforces write/admin server-side by checking the share record (the owner
|
||||||
|
always implicitly has admin via the owner share). See §"Permissions and
|
||||||
|
the admin-share question" below for the semantics of `admin`.
|
||||||
|
|
||||||
|
4. **Migration.** ✅ **Decided: no migration — wipe the database.** There is
|
||||||
|
no real user data, and the re-key cost is unjustified. Phase A therefore
|
||||||
|
includes a clean-cutover step rather than a per-document migration. (This
|
||||||
|
also removes any legacy single-DEK documents that would otherwise need
|
||||||
|
special-casing.)
|
||||||
|
|
||||||
|
5. **Existing `Share` model fate.** ✅ **Decided: remove entirely.**
|
||||||
|
`backend/src/models/share.rs`, the `/api/shares` routes, and the
|
||||||
|
`permissions/check` endpoint are deleted; the new `profile_shares`
|
||||||
|
collection and `/api/profiles/:id/shares` endpoints replace them. The
|
||||||
|
future per-resource physician-share feature will be built from scratch on
|
||||||
|
its own model when prioritized (it's a different access pattern — one-way
|
||||||
|
document dump, not ongoing profile access).
|
||||||
|
|
||||||
|
## Phasing (proposal)
|
||||||
|
|
||||||
|
- **Phase A — key tiers without sharing.** Introduce per-profile DEKs and the
|
||||||
|
account X25519 keypair; migrate existing data; no sharing endpoints yet.
|
||||||
|
Unblocks multi-profile-within-account (the parent/child single-login case).
|
||||||
|
- **Phase B — profile sharing.** The `POST/GET/DELETE /profiles/:id/shares`
|
||||||
|
endpoints and soft revoke.
|
||||||
|
- **Phase C — hard revoke / re-key.** The `/profiles/:id/rekey` endpoint and
|
||||||
|
owner-driven DEK rotation.
|
||||||
|
- **Phase D — graduation / ownership transfer.** Owner-share swap UI + endpoint.
|
||||||
|
|
||||||
|
Each phase is independently shippable and independently testable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Issue: [#3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3)
|
||||||
|
- Predecessor ADR: [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md)
|
||||||
|
- Current client crypto: `web/normogen-web/src/crypto/keys.ts`, `cipher.ts`
|
||||||
|
- Profile model: `backend/src/models/profile.rs`
|
||||||
|
- (Incompatible) current share model: `backend/src/models/share.rs`
|
||||||
|
- Web Crypto API: X25519 via `generateKey({name:"ECDH", namedCurve:"X25519"})`,
|
||||||
|
`deriveBits` for ECDH, `wrapKey`/`unwrapKey` or manual AES-GCM for the envelope.
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
# Encryption.md Update Summary
|
# Encryption.md Update Summary
|
||||||
|
|
||||||
|
> **ARCHIVED 2026-07-18.** This was a changelog note for a 2026-03-09 rewrite of
|
||||||
|
> `docs/product/encryption.md` that itself became stale (it describes ZK
|
||||||
|
> encryption as "planned" when it is in fact implemented). Kept here for
|
||||||
|
> history only. The current, accurate encryption doc is
|
||||||
|
> [`docs/product/encryption.md`](../product/encryption.md), and the canonical
|
||||||
|
> design is [`docs/adr/zero-knowledge-encryption.md`](../adr/zero-knowledge-encryption.md).
|
||||||
|
> See issue #4.
|
||||||
|
|
||||||
**Date**: 2026-03-09
|
**Date**: 2026-03-09
|
||||||
**File**: docs/product/encryption.md
|
**File**: docs/product/encryption.md
|
||||||
**Update**: Added Rust implementation examples and current status
|
**Update**: Added Rust implementation examples and current status
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
# Persona and Family Management - Product Definition
|
# Persona and Family Management - Product Definition
|
||||||
|
|
||||||
**Document Version**: 1.0
|
**Document Version**: 1.1
|
||||||
**Date**: 2026-03-09
|
**Date**: 2026-07-18 (reconciled with code; original draft 2026-03-09)
|
||||||
**Status**: Draft - For Refinement
|
**Status**: Vision doc — implementation status section corrected to match code
|
||||||
**Purpose**: Consolidate all current information about persona and family management features for further product refinement
|
**Purpose**: Consolidate all current information about persona and family management features for further product refinement
|
||||||
|
|
||||||
|
> **How to read this doc.** Sections marked **Vision** describe the target
|
||||||
|
> product and are still valid for discussion. Sections marked **Today (code)**
|
||||||
|
> describe what is actually implemented and have been verified against the
|
||||||
|
> codebase as of 2026-07-18. Where the two diverge, the gap is called out and
|
||||||
|
> linked to the relevant Forgejo issue.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Table of Contents
|
## 📋 Table of Contents
|
||||||
|
|
@ -35,6 +41,28 @@ Normogen supports **multi-person health data management** through a persona and
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Current reality (as of 2026-07-18)
|
||||||
|
|
||||||
|
This section corrects the earlier "Current Implementation Status" section below,
|
||||||
|
which described an earlier state. Verified against the code:
|
||||||
|
|
||||||
|
| Capability | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Single profile per user account | ✅ Done | `GET/PUT /api/profiles/me`; profile is auto-created as `profile_{user_id}` at registration. Display name is client-encrypted. |
|
||||||
|
| Multi-profile (child/dependent) within one account | ❌ Not built | `ProfileRepository::find_by_user_id` returns a single profile; no create/list/switch endpoints. `profileId` can be tagged onto medications but there is no backing entity beyond the owner's. |
|
||||||
|
| Family groups | ❌ Not built | `Family` model exists but has **no handler and no routes**. JWT carries `family_id` but nothing populates or checks it. |
|
||||||
|
| Cross-user sharing (spouse, caregiver) | ❌ **Blocked by design** | `/api/shares` routes exist but cannot grant decryption access — all data is encrypted under the owner's DEK and there is no key-distribution mechanism. See **[issue #3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3)**. |
|
||||||
|
| Caregiver roles / permissions | ❌ Not built | `permissions` field exists on Profile and in JWT claims but is not enforced. |
|
||||||
|
| Zero-knowledge encryption of health data | ✅ Done | Client-side Web Crypto, wrapped-DEK recovery. See [`encryption.md`](encryption.md). |
|
||||||
|
|
||||||
|
**Implication for the family/caregiver personas below:** the single biggest
|
||||||
|
open question is architectural — *how does sharing work under zero-knowledge?*
|
||||||
|
That must be decided before family/caregiver features can be implemented. Until
|
||||||
|
then, the realistic near-term scope is **multi-profile within a single account**
|
||||||
|
(one login managing several people's data, no cross-account sharing).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## User Personas
|
## User Personas
|
||||||
|
|
||||||
### Primary: Privacy-Conscious Individual
|
### Primary: Privacy-Conscious Individual
|
||||||
|
|
@ -167,7 +195,12 @@ User Account: jane.doe@example.com (Jane)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current Implementation Status
|
## Current Implementation Status (superseded — see "Current reality" above)
|
||||||
|
|
||||||
|
> The detail below was written 2026-03-09 against an earlier state and
|
||||||
|
> overstates what existed then. It is retained for history. For the accurate
|
||||||
|
> current picture, read the **Current reality** section near the top of this
|
||||||
|
> document, and [`encryption.md`](encryption.md) for encryption.
|
||||||
|
|
||||||
### ✅ Implemented (Backend)
|
### ✅ Implemented (Backend)
|
||||||
|
|
||||||
|
|
@ -595,16 +628,23 @@ if profile.role == "child" || profile.age < 18 {
|
||||||
|
|
||||||
### Encryption
|
### Encryption
|
||||||
|
|
||||||
**Zero-Knowledge Encryption**:
|
> **Correction (2026-07-18):** the bullets below described a planned
|
||||||
- Profile names are encrypted (AES-256-GCM)
|
> server-side scheme that was never built. The shipped system is
|
||||||
- Family names are encrypted
|
> **client-side** zero-knowledge via the browser's Web Crypto API with a
|
||||||
- Only users with correct password can decrypt
|
> wrapped-DEK model. The server has no crypto code at all — it is a blind
|
||||||
- Server cannot see profile names
|
> store. Profile/medication/appointment data is encrypted in the browser
|
||||||
|
> before upload and the server cannot read any of it. Full details:
|
||||||
|
> [`encryption.md`](encryption.md) and
|
||||||
|
> [`adr/zero-knowledge-encryption.md`](../adr/zero-knowledge-encryption.md).
|
||||||
|
|
||||||
**Encrypted Fields**:
|
**What's actually encrypted (client-side, today):**
|
||||||
- `profile.name` - Person's name
|
- Medication data blob (name, dosage, frequency, route, notes, …)
|
||||||
- `family.name` - Family group name
|
- Appointment data blob (title, provider, date/time, …)
|
||||||
- Medication data (when implemented)
|
- Profile display name
|
||||||
|
|
||||||
|
**What stays plaintext (queryable by the server):**
|
||||||
|
- IDs (`profileId`, `medicationId`, `userId`, `appointmentId`)
|
||||||
|
- Medication `active` flag, appointment `status`, `doseSchedule`, timestamps
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -24,7 +24,7 @@ import EditIcon from '@mui/icons-material/Edit';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { useAppointmentStore, useAuthStore } from '../../store/useStore';
|
import { useAppointmentStore, useProfileStore } from '../../store/useStore';
|
||||||
import type { Appointment } from '../../types/api';
|
import type { Appointment } from '../../types/api';
|
||||||
|
|
||||||
const APPT_TYPES = ['in-person', 'telehealth', 'lab', 'test', 'other'];
|
const APPT_TYPES = ['in-person', 'telehealth', 'lab', 'test', 'other'];
|
||||||
|
|
@ -74,7 +74,7 @@ export const AppointmentsManager: FC = () => {
|
||||||
deleteAppointment,
|
deleteAppointment,
|
||||||
clearError,
|
clearError,
|
||||||
} = useAppointmentStore();
|
} = useAppointmentStore();
|
||||||
const user = useAuthStore((s) => s.user);
|
const activeProfileId = useProfileStore((s) => s.activeProfileId);
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [createForm, setCreateForm] = useState<ApptFormData>(emptyCreate);
|
const [createForm, setCreateForm] = useState<ApptFormData>(emptyCreate);
|
||||||
|
|
@ -84,13 +84,13 @@ export const AppointmentsManager: FC = () => {
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAppointments();
|
if (activeProfileId) loadAppointments();
|
||||||
}, [loadAppointments]);
|
}, [loadAppointments, activeProfileId]);
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setCreateForm({
|
setCreateForm({
|
||||||
...emptyCreate,
|
...emptyCreate,
|
||||||
profile_id: `profile_${user?.user_id ?? 'default'}`,
|
profile_id: activeProfileId ?? 'default',
|
||||||
});
|
});
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ describe('MedicationManager', () => {
|
||||||
resetMockStore();
|
resetMockStore();
|
||||||
setMockStore({
|
setMockStore({
|
||||||
useAuthStore: { user: { user_id: 'u1', username: 'tester' }, profile: {} },
|
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();
|
baseActions.loadMedications.mockClear();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import {
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import { useMedicationStore, useAuthStore } from '../../store/useStore';
|
import { useMedicationStore, useProfileStore } from '../../store/useStore';
|
||||||
import type { Medication } from '../../types/api';
|
import type { Medication } from '../../types/api';
|
||||||
import { DoseLogger } from './DoseLogger';
|
import { DoseLogger } from './DoseLogger';
|
||||||
|
|
||||||
|
|
@ -60,7 +60,7 @@ export const MedicationManager: FC = () => {
|
||||||
deleteMedication,
|
deleteMedication,
|
||||||
clearError,
|
clearError,
|
||||||
} = useMedicationStore();
|
} = useMedicationStore();
|
||||||
const user = useAuthStore((s) => s.user);
|
const activeProfileId = useProfileStore((s) => s.activeProfileId);
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [createForm, setCreateForm] = useState<MedFormData>(emptyCreate);
|
const [createForm, setCreateForm] = useState<MedFormData>(emptyCreate);
|
||||||
|
|
@ -69,17 +69,16 @@ export const MedicationManager: FC = () => {
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Medication | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Medication | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
loadMedications();
|
if (activeProfileId) loadMedications();
|
||||||
}, [loadMedications]);
|
}, [loadMedications, activeProfileId]);
|
||||||
|
|
||||||
const openCreate = () => {
|
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({
|
setCreateForm({
|
||||||
...emptyCreate,
|
...emptyCreate,
|
||||||
profile_id: profileId,
|
profile_id: activeProfileId ?? 'default',
|
||||||
});
|
});
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,34 +7,62 @@ import {
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Alert,
|
Alert,
|
||||||
Chip,
|
Chip,
|
||||||
|
MenuItem,
|
||||||
Stack,
|
Stack,
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import SaveIcon from '@mui/icons-material/Save';
|
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 { useProfileStore, useAuthStore } from '../../store/useStore';
|
||||||
|
import { ProfileSharing } from './ProfileSharing';
|
||||||
|
|
||||||
export const ProfileEditor: FC = () => {
|
export const ProfileEditor: FC = () => {
|
||||||
const { profile, isLoading, error, loadProfile, updateName, clearError } =
|
const {
|
||||||
useProfileStore();
|
profiles,
|
||||||
|
activeProfileId,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
loadProfiles,
|
||||||
|
updateProfile,
|
||||||
|
createProfile,
|
||||||
|
deleteProfile,
|
||||||
|
clearError,
|
||||||
|
} = useProfileStore();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
|
const active = profiles.find((p) => p.profile_id === activeProfileId) ?? null;
|
||||||
|
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
const [kind, setKind] = useState('human');
|
||||||
|
const [relationship, setRelationship] = useState('');
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
// Create-profile dialog state
|
||||||
loadProfile();
|
const [creating, setCreating] = useState(false);
|
||||||
}, [loadProfile]);
|
const [newName, setNewName] = useState('');
|
||||||
|
const [newKind, setNewKind] = useState('human');
|
||||||
|
const [newRel, setNewRel] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (profile?.name) setName(profile.name);
|
loadProfiles();
|
||||||
}, [profile]);
|
}, [loadProfiles]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (active) {
|
||||||
|
setName(active.name);
|
||||||
|
setKind(active.kind || 'human');
|
||||||
|
setRelationship(active.relationship || '');
|
||||||
|
}
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
if (!active) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await updateName(name);
|
await updateProfile(active.profile_id, { name, kind, relationship });
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
} catch {
|
} catch {
|
||||||
/* error surfaced via store */
|
/* error surfaced via store */
|
||||||
|
|
@ -43,11 +71,43 @@ 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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
|
||||||
Profile
|
<Typography variant="h6">Profiles</Typography>
|
||||||
</Typography>
|
<Button startIcon={<AddIcon />} onClick={() => setCreating(true)}>
|
||||||
|
Add profile
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert severity="error" sx={{ mb: 2 }} onClose={clearError}>
|
<Alert severity="error" sx={{ mb: 2 }} onClose={clearError}>
|
||||||
|
|
@ -55,11 +115,11 @@ export const ProfileEditor: FC = () => {
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading && !profile ? (
|
{isLoading && profiles.length === 0 ? (
|
||||||
<Box display="flex" justifyContent="center" py={4}>
|
<Box display="flex" justifyContent="center" py={4}>
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : active ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
|
|
@ -84,39 +144,154 @@ export const ProfileEditor: FC = () => {
|
||||||
>
|
>
|
||||||
{saving ? <CircularProgress size={24} /> : 'Save'}
|
{saving ? <CircularProgress size={24} /> : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => { setEditing(false); setName(profile?.name ?? ''); }}>
|
<Button onClick={() => { setEditing(false); setName(active.name); }}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Stack direction="row" alignItems="center" spacing={1}>
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
<Typography variant="h6">{profile?.name ?? user?.username ?? '—'}</Typography>
|
<Typography variant="h6">{active.name || user?.username || '—'}</Typography>
|
||||||
<Button size="small" onClick={() => setEditing(true)}>Edit</Button>
|
{active.is_shared ? (
|
||||||
|
<Chip size="small" label="shared with you" color="info" variant="outlined" />
|
||||||
|
) : (
|
||||||
|
<Button size="small" onClick={() => setEditing(true)}>Edit</Button>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box>
|
{editing && (
|
||||||
<Typography variant="caption" color="text.secondary">
|
<>
|
||||||
Account
|
<Box>
|
||||||
</Typography>
|
<Typography variant="caption" color="text.secondary">Kind</Typography>
|
||||||
<Typography variant="body2">{user?.email}</Typography>
|
<TextField
|
||||||
</Box>
|
select
|
||||||
|
size="small"
|
||||||
<Box>
|
fullWidth
|
||||||
<Typography variant="caption" color="text.secondary">
|
value={kind}
|
||||||
Role
|
onChange={(e) => setKind(e.target.value)}
|
||||||
</Typography>
|
sx={{ mt: 0.5 }}
|
||||||
<Box sx={{ mt: 0.5 }}>
|
>
|
||||||
<Chip size="small" label={profile?.role ?? 'patient'} variant="outlined" />
|
<MenuItem value="human">Human</MenuItem>
|
||||||
</Box>
|
<MenuItem value="pet">Pet</MenuItem>
|
||||||
</Box>
|
</TextField>
|
||||||
|
</Box>
|
||||||
{profile?.profile_id && (
|
<Box>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
Profile ID: {profile.profile_id}
|
Relationship
|
||||||
</Typography>
|
</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>
|
||||||
|
</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>
|
||||||
</Stack>
|
</Stack>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
189
web/normogen-web/src/components/profile/ProfileSharing.tsx
Normal file
189
web/normogen-web/src/components/profile/ProfileSharing.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
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;
|
||||||
66
web/normogen-web/src/components/profile/ProfileSwitcher.tsx
Normal file
66
web/normogen-web/src/components/profile/ProfileSwitcher.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
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;
|
||||||
|
|
@ -6,11 +6,43 @@ import {
|
||||||
rewrapDek,
|
rewrapDek,
|
||||||
encryptJson,
|
encryptJson,
|
||||||
decryptJson,
|
decryptJson,
|
||||||
|
encrypt,
|
||||||
|
decrypt,
|
||||||
setEncKey,
|
setEncKey,
|
||||||
clearEncKey,
|
clearEncKey,
|
||||||
hasEncKey,
|
hasEncKey,
|
||||||
|
generateIdentityKeyPair,
|
||||||
|
wrapIdentityPrivateKey,
|
||||||
|
unwrapIdentityPrivateKey,
|
||||||
|
setIdentityPrivate,
|
||||||
|
clearIdentityPrivate,
|
||||||
|
hasIdentityPrivate,
|
||||||
|
generateProfileDek,
|
||||||
|
wrapProfileDek,
|
||||||
|
unwrapProfileDek,
|
||||||
|
wrapProfileDekToRecipient,
|
||||||
|
unwrapProfileDekFromShare,
|
||||||
|
setProfileDek,
|
||||||
|
getProfileDek,
|
||||||
|
clearProfileDeks,
|
||||||
} from './index';
|
} 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', () => {
|
describe('zero-knowledge crypto lifecycle', () => {
|
||||||
const password = 'my-secret-password';
|
const password = 'my-secret-password';
|
||||||
const recoveryPhrase = 'my-recovery-phrase';
|
const recoveryPhrase = 'my-recovery-phrase';
|
||||||
|
|
@ -99,3 +131,205 @@ describe('zero-knowledge crypto lifecycle', () => {
|
||||||
await expect(decryptJson(encrypted, setupB.dek)).rejects.toThrow();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,29 @@ export {
|
||||||
setupEncryption,
|
setupEncryption,
|
||||||
unlockWithPassword,
|
unlockWithPassword,
|
||||||
unlockWithRecovery,
|
unlockWithRecovery,
|
||||||
|
generateIdentityKeyPair,
|
||||||
|
wrapIdentityPrivateKey,
|
||||||
|
unwrapIdentityPrivateKey,
|
||||||
|
generateProfileDek,
|
||||||
|
wrapProfileDek,
|
||||||
|
unwrapProfileDek,
|
||||||
|
wrapProfileDekToRecipient,
|
||||||
|
unwrapProfileDekFromShare,
|
||||||
|
type SharedProfileDekEnvelope,
|
||||||
setEncKey,
|
setEncKey,
|
||||||
getEncKey,
|
getEncKey,
|
||||||
clearEncKey,
|
clearEncKey,
|
||||||
hasEncKey,
|
hasEncKey,
|
||||||
|
setIdentityPrivate,
|
||||||
|
getIdentityPrivate,
|
||||||
|
clearIdentityPrivate,
|
||||||
|
hasIdentityPrivate,
|
||||||
|
setProfileDek,
|
||||||
|
getProfileDek,
|
||||||
|
setActiveProfileId,
|
||||||
|
getActiveProfileId,
|
||||||
|
getActiveProfileDek,
|
||||||
|
clearProfileDeks,
|
||||||
AUTH_SALT,
|
AUTH_SALT,
|
||||||
PASSWORD_KEK_SALT,
|
PASSWORD_KEK_SALT,
|
||||||
RECOVERY_KEK_SALT,
|
RECOVERY_KEK_SALT,
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,130 @@ export async function rewrapDek(
|
||||||
return wrapDek(dek, newKek);
|
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
|
// High-level flows
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -185,6 +309,62 @@ export async function unlockWithRecovery(
|
||||||
return unwrapDek(recoveryWrappedDek, recoveryKek);
|
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)
|
// In-memory key store (unchanged from Phase 1)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -207,6 +387,67 @@ export function hasEncKey(): boolean {
|
||||||
return currentEncKey !== null;
|
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
|
// Backward compat: the old deriveAuthAndEncKeys (Phase 1 direct-from-password
|
||||||
// enc key). Kept for tests; new code uses setupEncryption/unlockWithPassword.
|
// enc key). Kept for tests; new code uses setupEncryption/unlockWithPassword.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { MedicationManager } from '../components/medication/MedicationManager';
|
||||||
import { HealthStats } from '../components/health/HealthStats';
|
import { HealthStats } from '../components/health/HealthStats';
|
||||||
import { InteractionsChecker } from '../components/interactions/InteractionsChecker';
|
import { InteractionsChecker } from '../components/interactions/InteractionsChecker';
|
||||||
import { ProfileEditor } from '../components/profile/ProfileEditor';
|
import { ProfileEditor } from '../components/profile/ProfileEditor';
|
||||||
|
import { ProfileSwitcher } from '../components/profile/ProfileSwitcher';
|
||||||
import { AppointmentsManager } from '../components/appointments/AppointmentsManager';
|
import { AppointmentsManager } from '../components/appointments/AppointmentsManager';
|
||||||
|
|
||||||
type TabIndex = 0 | 1 | 2 | 3 | 4;
|
type TabIndex = 0 | 1 | 2 | 3 | 4;
|
||||||
|
|
@ -35,6 +36,7 @@ export const Dashboard: FC = () => {
|
||||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||||
Normogen
|
Normogen
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<ProfileSwitcher />
|
||||||
{user && (
|
{user && (
|
||||||
<Typography variant="body2" sx={{ mr: 2, opacity: 0.85 }}>
|
<Typography variant="body2" sx={{ mr: 2, opacity: 0.85 }}>
|
||||||
{user.username}
|
{user.username}
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,23 @@ import {
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Lock as LockIcon } from '@mui/icons-material';
|
import { Lock as LockIcon } from '@mui/icons-material';
|
||||||
import { useAuthStore } from '../store/useStore';
|
import { useAuthStore } from '../store/useStore';
|
||||||
import { unlockWithPassword, deriveAuthAndEncKeys, setEncKey } from '../crypto';
|
import {
|
||||||
|
unlockWithPassword,
|
||||||
|
deriveAuthAndEncKeys,
|
||||||
|
unwrapIdentityPrivateKey,
|
||||||
|
setEncKey,
|
||||||
|
setIdentityPrivate,
|
||||||
|
} from '../crypto';
|
||||||
|
|
||||||
export const UnlockPage: FC = () => {
|
export const UnlockPage: FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { wrapped_dek, wrapped_dek_iv, user } = useAuthStore();
|
const {
|
||||||
|
wrapped_dek,
|
||||||
|
wrapped_dek_iv,
|
||||||
|
identity_private_key_wrapped,
|
||||||
|
identity_private_key_wrapped_iv,
|
||||||
|
user,
|
||||||
|
} = useAuthStore();
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [unlocking, setUnlocking] = useState(false);
|
const [unlocking, setUnlocking] = useState(false);
|
||||||
|
|
@ -27,13 +39,32 @@ export const UnlockPage: FC = () => {
|
||||||
setUnlocking(true);
|
setUnlocking(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let dek;
|
||||||
if (wrapped_dek && wrapped_dek_iv) {
|
if (wrapped_dek && wrapped_dek_iv) {
|
||||||
// Wrapped-DEK model: unwrap the DEK using the password.
|
// Wrapped-DEK model: unwrap the DEK using the password.
|
||||||
const { dek } = await unlockWithPassword(password, {
|
({ dek } = await unlockWithPassword(password, {
|
||||||
data: wrapped_dek,
|
data: wrapped_dek,
|
||||||
iv: wrapped_dek_iv,
|
iv: wrapped_dek_iv,
|
||||||
});
|
}));
|
||||||
setEncKey(dek);
|
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 {
|
} else {
|
||||||
// Phase 1 compat: derive the key directly from the password.
|
// Phase 1 compat: derive the key directly from the password.
|
||||||
const { encKey } = await deriveAuthAndEncKeys(password);
|
const { encKey } = await deriveAuthAndEncKeys(password);
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,14 @@ import {
|
||||||
MedicationWireResponse,
|
MedicationWireResponse,
|
||||||
AppointmentWireResponse,
|
AppointmentWireResponse,
|
||||||
ProfileWireResponse,
|
ProfileWireResponse,
|
||||||
|
CreateProfileRequest,
|
||||||
|
UpdateProfileRequest,
|
||||||
|
SharedProfileWireResponse,
|
||||||
|
CreateProfileShareRequest,
|
||||||
|
ProfileShareListing,
|
||||||
|
PublicKeyResponse,
|
||||||
|
RekeyRequest,
|
||||||
|
RekeyResponse,
|
||||||
HealthStatWireResponse,
|
HealthStatWireResponse,
|
||||||
UpdateHealthStatRequest,
|
UpdateHealthStatRequest,
|
||||||
EncryptedFieldWire,
|
EncryptedFieldWire,
|
||||||
|
|
@ -232,25 +240,93 @@ class ApiService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Profile (zero-knowledge: opaque encrypted name) ----
|
// ---- Profiles (Phase A2: multi-profile, per-profile DEKs) ----
|
||||||
|
|
||||||
async getProfile(): Promise<ProfileWireResponse> {
|
async listProfiles(): Promise<ProfileWireResponse[]> {
|
||||||
const response = await this.client.get<ProfileWireResponse>('/profiles/me');
|
const response = await this.client.get<ProfileWireResponse[]>('/profiles');
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateProfileName(nameData: string, nameIv: string): Promise<ProfileWireResponse> {
|
async createProfile(req: CreateProfileRequest): Promise<ProfileWireResponse> {
|
||||||
const response = await this.client.put<ProfileWireResponse>('/profiles/me', {
|
const response = await this.client.post<ProfileWireResponse>('/profiles', req);
|
||||||
name_data: nameData,
|
return response.data;
|
||||||
name_iv: nameIv,
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
});
|
});
|
||||||
return response.data;
|
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) ----
|
// ---- Medications (zero-knowledge: opaque encrypted blobs) ----
|
||||||
|
|
||||||
async getMedications(): Promise<MedicationWireResponse[]> {
|
async getMedications(profileId?: string): Promise<MedicationWireResponse[]> {
|
||||||
const response = await this.client.get<MedicationWireResponse[]>('/medications');
|
const params = profileId ? { profile_id: profileId } : undefined;
|
||||||
|
const response = await this.client.get<MedicationWireResponse[]>('/medications', { params });
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -287,9 +363,12 @@ class ApiService {
|
||||||
|
|
||||||
// ---- Appointments (zero-knowledge: opaque encrypted blobs) ----
|
// ---- Appointments (zero-knowledge: opaque encrypted blobs) ----
|
||||||
|
|
||||||
async getAppointments(status?: string): Promise<AppointmentWireResponse[]> {
|
async getAppointments(status?: string, profileId?: string): Promise<AppointmentWireResponse[]> {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (status) params.status = status;
|
||||||
|
if (profileId) params.profile_id = profileId;
|
||||||
const response = await this.client.get<AppointmentWireResponse[]>('/appointments', {
|
const response = await this.client.get<AppointmentWireResponse[]>('/appointments', {
|
||||||
params: status ? { status } : undefined,
|
params: Object.keys(params).length ? params : undefined,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
@ -332,8 +411,9 @@ class ApiService {
|
||||||
|
|
||||||
// ---- Health Statistics (zero-knowledge: opaque encrypted blobs) ----
|
// ---- Health Statistics (zero-knowledge: opaque encrypted blobs) ----
|
||||||
|
|
||||||
async getHealthStats(): Promise<HealthStatWireResponse[]> {
|
async getHealthStats(profileId?: string): Promise<HealthStatWireResponse[]> {
|
||||||
const response = await this.client.get<HealthStatWireResponse[]>('/health-stats');
|
const params = profileId ? { profile_id: profileId } : undefined;
|
||||||
|
const response = await this.client.get<HealthStatWireResponse[]>('/health-stats', { params });
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import {
|
||||||
deriveAuthAndEncKeys,
|
deriveAuthAndEncKeys,
|
||||||
setEncKey,
|
setEncKey,
|
||||||
clearEncKey,
|
clearEncKey,
|
||||||
|
setActiveProfileId,
|
||||||
|
setProfileDek,
|
||||||
|
clearProfileDeks,
|
||||||
encryptJson,
|
encryptJson,
|
||||||
setupEncryption,
|
setupEncryption,
|
||||||
unlockWithPassword,
|
unlockWithPassword,
|
||||||
|
|
@ -22,6 +25,10 @@ vi.mock('../services/api', () => ({ default: apiMock }));
|
||||||
// Import the store AFTER the mock is registered.
|
// Import the store AFTER the mock is registered.
|
||||||
const { useMedicationStore } = await import('./useStore');
|
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', () => {
|
describe('useMedicationStore', () => {
|
||||||
let encKey: CryptoKey;
|
let encKey: CryptoKey;
|
||||||
|
|
||||||
|
|
@ -30,6 +37,10 @@ describe('useMedicationStore', () => {
|
||||||
const { encKey: key } = await deriveAuthAndEncKeys('test-password');
|
const { encKey: key } = await deriveAuthAndEncKeys('test-password');
|
||||||
encKey = key;
|
encKey = key;
|
||||||
setEncKey(encKey);
|
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({
|
useMedicationStore.setState({
|
||||||
medications: [],
|
medications: [],
|
||||||
|
|
@ -44,6 +55,7 @@ describe('useMedicationStore', () => {
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
clearEncKey();
|
clearEncKey();
|
||||||
|
clearProfileDeks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loadMedications decrypts wire responses into domain medications', async () => {
|
it('loadMedications decrypts wire responses into domain medications', async () => {
|
||||||
|
|
@ -74,6 +86,7 @@ describe('useMedicationStore', () => {
|
||||||
|
|
||||||
it('loadMedications sets an error when no enc key is available', async () => {
|
it('loadMedications sets an error when no enc key is available', async () => {
|
||||||
clearEncKey();
|
clearEncKey();
|
||||||
|
clearProfileDeks();
|
||||||
await useMedicationStore.getState().loadMedications();
|
await useMedicationStore.getState().loadMedications();
|
||||||
expect(useMedicationStore.getState().error).toMatch(/No encryption key/);
|
expect(useMedicationStore.getState().error).toMatch(/No encryption key/);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,28 @@ import {
|
||||||
unlockWithPassword,
|
unlockWithPassword,
|
||||||
unlockWithRecovery,
|
unlockWithRecovery,
|
||||||
rewrapDek,
|
rewrapDek,
|
||||||
|
generateIdentityKeyPair,
|
||||||
|
wrapIdentityPrivateKey,
|
||||||
|
unwrapIdentityPrivateKey,
|
||||||
|
generateProfileDek,
|
||||||
|
wrapProfileDek,
|
||||||
|
unwrapProfileDek,
|
||||||
|
wrapProfileDekToRecipient,
|
||||||
|
unwrapProfileDekFromShare,
|
||||||
|
encrypt as encryptRaw,
|
||||||
|
decrypt as decryptRaw,
|
||||||
setEncKey,
|
setEncKey,
|
||||||
clearEncKey,
|
clearEncKey,
|
||||||
|
clearIdentityPrivate,
|
||||||
|
clearProfileDeks,
|
||||||
|
setIdentityPrivate,
|
||||||
|
getIdentityPrivate,
|
||||||
getEncKey,
|
getEncKey,
|
||||||
|
getActiveProfileDek,
|
||||||
|
getActiveProfileId,
|
||||||
|
setActiveProfileId,
|
||||||
|
setProfileDek,
|
||||||
|
getProfileDek,
|
||||||
encryptJson,
|
encryptJson,
|
||||||
decryptJson,
|
decryptJson,
|
||||||
type CipherPayload,
|
type CipherPayload,
|
||||||
|
|
@ -21,6 +40,10 @@ import {
|
||||||
DrugInteraction,
|
DrugInteraction,
|
||||||
AdherenceStats,
|
AdherenceStats,
|
||||||
Profile,
|
Profile,
|
||||||
|
ProfileShareListing,
|
||||||
|
MedicationWireResponse,
|
||||||
|
AppointmentWireResponse,
|
||||||
|
HealthStatWireResponse,
|
||||||
Appointment,
|
Appointment,
|
||||||
CreateAppointmentRequest,
|
CreateAppointmentRequest,
|
||||||
UpdateAppointmentRequest,
|
UpdateAppointmentRequest,
|
||||||
|
|
@ -38,6 +61,12 @@ interface AuthState {
|
||||||
// on page reload without a full re-login.
|
// on page reload without a full re-login.
|
||||||
wrapped_dek: string | null;
|
wrapped_dek: string | null;
|
||||||
wrapped_dek_iv: 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
|
// Actions
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
|
@ -95,11 +124,46 @@ interface InteractionState {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProfileState {
|
interface ProfileState {
|
||||||
profile: Profile | null;
|
profiles: Profile[];
|
||||||
|
activeProfileId: string | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
loadProfile: () => Promise<void>;
|
/** Fetch all owned profiles and unwrap each per-profile DEK under the account
|
||||||
updateName: (name: string) => Promise<void>;
|
* 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>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,6 +192,9 @@ export const useAuthStore = create<AuthState>()(
|
||||||
error: null,
|
error: null,
|
||||||
wrapped_dek: null,
|
wrapped_dek: null,
|
||||||
wrapped_dek_iv: 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) => {
|
login: async (email: string, password: string) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
|
|
@ -144,6 +211,26 @@ export const useAuthStore = create<AuthState>()(
|
||||||
iv: response.wrapped_dek_iv,
|
iv: response.wrapped_dek_iv,
|
||||||
});
|
});
|
||||||
setEncKey(dek);
|
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 {
|
} catch {
|
||||||
// Fallback to the PBKDF2-derived key (Phase 1 compat).
|
// Fallback to the PBKDF2-derived key (Phase 1 compat).
|
||||||
}
|
}
|
||||||
|
|
@ -160,6 +247,9 @@ export const useAuthStore = create<AuthState>()(
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
wrapped_dek: response.wrapped_dek ?? null,
|
wrapped_dek: response.wrapped_dek ?? null,
|
||||||
wrapped_dek_iv: response.wrapped_dek_iv ?? 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) {
|
} catch (error: any) {
|
||||||
clearEncKey();
|
clearEncKey();
|
||||||
|
|
@ -181,6 +271,21 @@ export const useAuthStore = create<AuthState>()(
|
||||||
const setup = await setupEncryption(password, recoveryPhrase);
|
const setup = await setupEncryption(password, recoveryPhrase);
|
||||||
setEncKey(setup.dek);
|
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({
|
const response = await apiService.register({
|
||||||
username,
|
username,
|
||||||
email,
|
email,
|
||||||
|
|
@ -190,7 +295,19 @@ export const useAuthStore = create<AuthState>()(
|
||||||
recovery_wrapped_dek: setup.recoveryWrappedDek?.data,
|
recovery_wrapped_dek: setup.recoveryWrappedDek?.data,
|
||||||
recovery_wrapped_dek_iv: setup.recoveryWrappedDek?.iv,
|
recovery_wrapped_dek_iv: setup.recoveryWrappedDek?.iv,
|
||||||
recovery_phrase: setup.recoveryKekHash,
|
recovery_phrase: setup.recoveryKekHash,
|
||||||
} as any);
|
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);
|
||||||
set({
|
set({
|
||||||
user: {
|
user: {
|
||||||
user_id: response.user_id,
|
user_id: response.user_id,
|
||||||
|
|
@ -203,6 +320,9 @@ export const useAuthStore = create<AuthState>()(
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
wrapped_dek: setup.passwordWrappedDek.data,
|
wrapped_dek: setup.passwordWrappedDek.data,
|
||||||
wrapped_dek_iv: setup.passwordWrappedDek.iv,
|
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) {
|
} catch (error: any) {
|
||||||
set({
|
set({
|
||||||
|
|
@ -254,6 +374,8 @@ export const useAuthStore = create<AuthState>()(
|
||||||
|
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
clearEncKey();
|
clearEncKey();
|
||||||
|
clearIdentityPrivate();
|
||||||
|
clearProfileDeks();
|
||||||
await apiService.logout();
|
await apiService.logout();
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
|
|
@ -262,6 +384,9 @@ export const useAuthStore = create<AuthState>()(
|
||||||
error: null,
|
error: null,
|
||||||
wrapped_dek: null,
|
wrapped_dek: null,
|
||||||
wrapped_dek_iv: null,
|
wrapped_dek_iv: null,
|
||||||
|
identity_public_key: null,
|
||||||
|
identity_private_key_wrapped: null,
|
||||||
|
identity_private_key_wrapped_iv: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -296,6 +421,9 @@ export const useAuthStore = create<AuthState>()(
|
||||||
isAuthenticated: state.isAuthenticated,
|
isAuthenticated: state.isAuthenticated,
|
||||||
wrapped_dek: state.wrapped_dek,
|
wrapped_dek: state.wrapped_dek,
|
||||||
wrapped_dek_iv: state.wrapped_dek_iv,
|
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,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -312,14 +440,14 @@ export const useMedicationStore = create<MedicationState>()(
|
||||||
adherence: {},
|
adherence: {},
|
||||||
|
|
||||||
loadMedications: async () => {
|
loadMedications: async () => {
|
||||||
const key = getEncKey();
|
const key = getActiveProfileDek();
|
||||||
if (!key) {
|
if (!key) {
|
||||||
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const wireMeds = await apiService.getMedications();
|
const wireMeds = await apiService.getMedications(getActiveProfileId() ?? undefined);
|
||||||
// Decrypt each opaque blob into domain Medication objects.
|
// Decrypt each opaque blob into domain Medication objects.
|
||||||
const medications = await Promise.all(
|
const medications = await Promise.all(
|
||||||
wireMeds.map(async (w) => {
|
wireMeds.map(async (w) => {
|
||||||
|
|
@ -359,7 +487,7 @@ export const useMedicationStore = create<MedicationState>()(
|
||||||
},
|
},
|
||||||
|
|
||||||
createMedication: async (data: any) => {
|
createMedication: async (data: any) => {
|
||||||
const key = getEncKey();
|
const key = getActiveProfileDek();
|
||||||
if (!key) {
|
if (!key) {
|
||||||
set({ error: 'No encryption key — cannot encrypt data' });
|
set({ error: 'No encryption key — cannot encrypt data' });
|
||||||
throw new Error('No encryption key');
|
throw new Error('No encryption key');
|
||||||
|
|
@ -409,7 +537,7 @@ export const useMedicationStore = create<MedicationState>()(
|
||||||
},
|
},
|
||||||
|
|
||||||
updateMedication: async (id: string, data: any) => {
|
updateMedication: async (id: string, data: any) => {
|
||||||
const key = getEncKey();
|
const key = getActiveProfileDek();
|
||||||
if (!key) {
|
if (!key) {
|
||||||
set({ error: 'No encryption key — cannot encrypt data' });
|
set({ error: 'No encryption key — cannot encrypt data' });
|
||||||
throw new Error('No encryption key');
|
throw new Error('No encryption key');
|
||||||
|
|
@ -508,14 +636,14 @@ export const useHealthStore = create<HealthState>()(
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
loadStats: async () => {
|
loadStats: async () => {
|
||||||
const key = getEncKey();
|
const key = getActiveProfileDek();
|
||||||
if (!key) {
|
if (!key) {
|
||||||
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const wireStats = await apiService.getHealthStats();
|
const wireStats = await apiService.getHealthStats(getActiveProfileId() ?? undefined);
|
||||||
const stats = await Promise.all(
|
const stats = await Promise.all(
|
||||||
wireStats.map(async (w) => {
|
wireStats.map(async (w) => {
|
||||||
const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key);
|
const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key);
|
||||||
|
|
@ -542,7 +670,7 @@ export const useHealthStore = create<HealthState>()(
|
||||||
},
|
},
|
||||||
|
|
||||||
createStat: async (data: any) => {
|
createStat: async (data: any) => {
|
||||||
const key = getEncKey();
|
const key = getActiveProfileDek();
|
||||||
if (!key) {
|
if (!key) {
|
||||||
set({ error: 'No encryption key — cannot encrypt data' });
|
set({ error: 'No encryption key — cannot encrypt data' });
|
||||||
throw new Error('No encryption key');
|
throw new Error('No encryption key');
|
||||||
|
|
@ -550,6 +678,7 @@ export const useHealthStore = create<HealthState>()(
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const d = data as any;
|
const d = data as any;
|
||||||
|
const profileId = getActiveProfileId();
|
||||||
const encrypted_data = await encryptJson({
|
const encrypted_data = await encryptJson({
|
||||||
stat_type: d.stat_type,
|
stat_type: d.stat_type,
|
||||||
value: d.value,
|
value: d.value,
|
||||||
|
|
@ -557,6 +686,7 @@ export const useHealthStore = create<HealthState>()(
|
||||||
notes: d.notes,
|
notes: d.notes,
|
||||||
}, key);
|
}, key);
|
||||||
const wire = await apiService.createHealthStat({
|
const wire = await apiService.createHealthStat({
|
||||||
|
profile_id: profileId ?? '',
|
||||||
encrypted_data,
|
encrypted_data,
|
||||||
recorded_at: d.measured_at,
|
recorded_at: d.measured_at,
|
||||||
});
|
});
|
||||||
|
|
@ -583,7 +713,7 @@ export const useHealthStore = create<HealthState>()(
|
||||||
},
|
},
|
||||||
|
|
||||||
updateStat: async (id: string, data: any) => {
|
updateStat: async (id: string, data: any) => {
|
||||||
const key = getEncKey();
|
const key = getActiveProfileDek();
|
||||||
if (!key) {
|
if (!key) {
|
||||||
set({ error: 'No encryption key — cannot encrypt data' });
|
set({ error: 'No encryption key — cannot encrypt data' });
|
||||||
throw new Error('No encryption key');
|
throw new Error('No encryption key');
|
||||||
|
|
@ -695,71 +825,159 @@ export const useInteractionStore = create<InteractionState>()(
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Profile Store (Phase 3c)
|
// Profile Store (Phase A2: multi-profile + per-profile DEKs)
|
||||||
export const useProfileStore = create<ProfileState>()(
|
export const useProfileStore = create<ProfileState>()(
|
||||||
devtools((set, get) => ({
|
devtools((set, get) => ({
|
||||||
profile: null,
|
profiles: [],
|
||||||
|
activeProfileId: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
profileShares: {},
|
||||||
|
|
||||||
loadProfile: async () => {
|
loadProfiles: async () => {
|
||||||
const key = getEncKey();
|
const accountDek = getEncKey();
|
||||||
if (!key) {
|
if (!accountDek) {
|
||||||
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
set({ error: 'No account key — log in to decrypt profiles', isLoading: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const wire = await apiService.getProfile();
|
const wireList = await apiService.listProfiles();
|
||||||
// Decrypt the profile name.
|
// Unwrap each profile's DEK under the account DEK, and decrypt each
|
||||||
let name = '';
|
// profile's display name under that profile's DEK.
|
||||||
if (wire.name_data && wire.name_iv) {
|
const profiles: Profile[] = [];
|
||||||
try {
|
for (const wire of wireList) {
|
||||||
name = await decryptJson<string>({ data: wire.name_data, iv: wire.name_iv }, key);
|
let profileDek = getProfileDek(wire.profile_id);
|
||||||
} catch { name = ''; }
|
if (!profileDek) {
|
||||||
|
try {
|
||||||
|
profileDek = await unwrapProfileDek(
|
||||||
|
{ data: wire.wrapped_profile_dek, iv: wire.wrapped_profile_dek_iv },
|
||||||
|
accountDek,
|
||||||
|
);
|
||||||
|
setProfileDek(wire.profile_id, profileDek);
|
||||||
|
} catch {
|
||||||
|
// Could not unwrap this profile's DEK — skip it; the UI will
|
||||||
|
// show the profiles it could decrypt.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let name = '';
|
||||||
|
if (wire.name_data && wire.name_iv && profileDek) {
|
||||||
|
try {
|
||||||
|
name = await decryptRaw({ data: wire.name_data, iv: wire.name_iv }, profileDek);
|
||||||
|
} catch { name = ''; }
|
||||||
|
}
|
||||||
|
profiles.push({
|
||||||
|
profile_id: wire.profile_id,
|
||||||
|
owner_account_id: wire.owner_account_id,
|
||||||
|
name,
|
||||||
|
kind: wire.kind,
|
||||||
|
relationship: wire.relationship,
|
||||||
|
role: wire.role,
|
||||||
|
permissions: wire.permissions,
|
||||||
|
created_at: wire.created_at,
|
||||||
|
updated_at: wire.updated_at,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const profile: Profile = {
|
// Set the first profile active if none is selected (or if the active
|
||||||
profile_id: wire.profile_id,
|
// one is no longer present).
|
||||||
user_id: wire.user_id,
|
const currentActive = getActiveProfileId();
|
||||||
name,
|
const stillOwned = profiles.some((p) => p.profile_id === currentActive);
|
||||||
role: wire.role,
|
const newActive = stillOwned ? currentActive : (profiles[0]?.profile_id ?? null);
|
||||||
permissions: wire.permissions,
|
if (newActive) setActiveProfileId(newActive);
|
||||||
created_at: wire.created_at,
|
set({ profiles, activeProfileId: newActive, isLoading: false });
|
||||||
updated_at: wire.updated_at,
|
|
||||||
};
|
|
||||||
set({ profile, isLoading: false });
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
set({
|
set({
|
||||||
error: error.message || 'Failed to load profile',
|
error: error.message || 'Failed to load profiles',
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
updateName: async (name) => {
|
setActiveProfile: (profileId) => {
|
||||||
const key = getEncKey();
|
setActiveProfileId(profileId);
|
||||||
if (!key) {
|
set({ activeProfileId: profileId });
|
||||||
set({ error: 'No encryption key — cannot encrypt data' });
|
},
|
||||||
throw new Error('No encryption key');
|
|
||||||
|
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 });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const blob = await encryptJson(name, key);
|
// Generate a fresh profile DEK, wrap it under the account DEK, and
|
||||||
const wire = await apiService.updateProfileName(blob.data, blob.iv);
|
// encrypt the display name under the new profile DEK.
|
||||||
let decryptedName = name;
|
const profileDek = await generateProfileDek();
|
||||||
try {
|
const wrapped = await wrapProfileDek(profileDek, accountDek);
|
||||||
decryptedName = await decryptJson<string>({ data: wire.name_data, iv: wire.name_iv }, key);
|
const nameBlob = await encryptRaw(name, profileDek);
|
||||||
} catch { /* keep the input name */ }
|
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 = {
|
const profile: Profile = {
|
||||||
profile_id: wire.profile_id,
|
profile_id: wire.profile_id,
|
||||||
user_id: wire.user_id,
|
owner_account_id: wire.owner_account_id,
|
||||||
name: decryptedName,
|
name,
|
||||||
|
kind: wire.kind,
|
||||||
|
relationship: wire.relationship,
|
||||||
role: wire.role,
|
role: wire.role,
|
||||||
permissions: wire.permissions,
|
permissions: wire.permissions,
|
||||||
created_at: wire.created_at,
|
created_at: wire.created_at,
|
||||||
updated_at: wire.updated_at,
|
updated_at: wire.updated_at,
|
||||||
};
|
};
|
||||||
set({ profile, isLoading: false });
|
set((state) => ({
|
||||||
|
profiles: [...state.profiles, profile],
|
||||||
|
activeProfileId: wire.profile_id,
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
} catch (error: any) {
|
||||||
|
set({
|
||||||
|
error: error.message || 'Failed to create 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');
|
||||||
|
}
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const nameBlob = await encryptRaw(name, profileDek);
|
||||||
|
const wire = await apiService.updateProfile(profileId, {
|
||||||
|
name_data: nameBlob.data,
|
||||||
|
name_iv: nameBlob.iv,
|
||||||
|
kind: kind ?? 'human',
|
||||||
|
relationship: relationship ?? '',
|
||||||
|
});
|
||||||
|
set((state) => ({
|
||||||
|
profiles: state.profiles.map((p) =>
|
||||||
|
p.profile_id === profileId
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
name,
|
||||||
|
kind: wire.kind,
|
||||||
|
relationship: wire.relationship,
|
||||||
|
updated_at: wire.updated_at,
|
||||||
|
}
|
||||||
|
: p,
|
||||||
|
),
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
set({
|
set({
|
||||||
error: error.message || 'Failed to update profile',
|
error: error.message || 'Failed to update profile',
|
||||||
|
|
@ -769,6 +987,272 @@ 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 }),
|
clearError: () => set({ error: null }),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
@ -781,14 +1265,14 @@ export const useAppointmentStore = create<AppointmentState>()(
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
loadAppointments: async (status) => {
|
loadAppointments: async (status) => {
|
||||||
const key = getEncKey();
|
const key = getActiveProfileDek();
|
||||||
if (!key) {
|
if (!key) {
|
||||||
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const wireAppts = await apiService.getAppointments(status);
|
const wireAppts = await apiService.getAppointments(status, getActiveProfileId() ?? undefined);
|
||||||
const appointments = await Promise.all(
|
const appointments = await Promise.all(
|
||||||
wireAppts.map(async (w) => {
|
wireAppts.map(async (w) => {
|
||||||
const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key);
|
const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key);
|
||||||
|
|
@ -821,7 +1305,7 @@ export const useAppointmentStore = create<AppointmentState>()(
|
||||||
},
|
},
|
||||||
|
|
||||||
createAppointment: async (data) => {
|
createAppointment: async (data) => {
|
||||||
const key = getEncKey();
|
const key = getActiveProfileDek();
|
||||||
if (!key) {
|
if (!key) {
|
||||||
set({ error: 'No encryption key — cannot encrypt data' });
|
set({ error: 'No encryption key — cannot encrypt data' });
|
||||||
throw new Error('No encryption key');
|
throw new Error('No encryption key');
|
||||||
|
|
@ -867,7 +1351,7 @@ export const useAppointmentStore = create<AppointmentState>()(
|
||||||
},
|
},
|
||||||
|
|
||||||
updateAppointment: async (id, data) => {
|
updateAppointment: async (id, data) => {
|
||||||
const key = getEncKey();
|
const key = getActiveProfileDek();
|
||||||
if (!key) {
|
if (!key) {
|
||||||
set({ error: 'No encryption key — cannot encrypt data' });
|
set({ error: 'No encryption key — cannot encrypt data' });
|
||||||
throw new Error('No encryption key');
|
throw new Error('No encryption key');
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,11 @@ export interface AuthTokens {
|
||||||
email?: string;
|
email?: string;
|
||||||
wrapped_dek?: string;
|
wrapped_dek?: string;
|
||||||
wrapped_dek_iv?: 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 {
|
export interface LoginRequest {
|
||||||
|
|
@ -54,6 +59,25 @@ export interface RegisterRequest {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
role?: 'patient' | 'provider' | 'admin';
|
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
|
// Medication Types
|
||||||
|
|
@ -223,11 +247,13 @@ export interface HealthStat {
|
||||||
export interface HealthStatWireResponse {
|
export interface HealthStatWireResponse {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
|
profile_id: string;
|
||||||
encrypted_data: EncryptedFieldWire;
|
encrypted_data: EncryptedFieldWire;
|
||||||
recorded_at: string;
|
recorded_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateHealthStatRequest {
|
export interface CreateHealthStatRequest {
|
||||||
|
profile_id: string;
|
||||||
encrypted_data: EncryptedFieldWire;
|
encrypted_data: EncryptedFieldWire;
|
||||||
recorded_at?: string;
|
recorded_at?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -307,18 +333,110 @@ export interface UpdateAppointmentRequest {
|
||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Profile wire response (opaque encrypted name)
|
// Profile wire response (opaque encrypted name + wrapped profile DEK)
|
||||||
export interface ProfileWireResponse {
|
export interface ProfileWireResponse {
|
||||||
profile_id: string;
|
profile_id: string;
|
||||||
user_id: string;
|
owner_account_id: string;
|
||||||
name_data: string;
|
name_data: string;
|
||||||
name_iv: string;
|
name_iv: string;
|
||||||
|
kind: string;
|
||||||
|
relationship: string;
|
||||||
role: string;
|
role: string;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
|
/** Profile DEK wrapped under the account DEK (opaque ciphertext). */
|
||||||
|
wrapped_profile_dek: string;
|
||||||
|
wrapped_profile_dek_iv: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_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).
|
// Dose Log Types — match backend MedicationDose (camelCase serialization).
|
||||||
export interface DoseLog {
|
export interface DoseLog {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|
@ -351,12 +469,18 @@ export interface AdherenceStats {
|
||||||
// Profile Types — match backend ProfileResponse.
|
// Profile Types — match backend ProfileResponse.
|
||||||
export interface Profile {
|
export interface Profile {
|
||||||
profile_id: string;
|
profile_id: string;
|
||||||
user_id: string;
|
owner_account_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
relationship: string;
|
||||||
role: string;
|
role: string;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_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
|
// Error Types
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue