Compare commits
No commits in common. "main" and "ci/fix-mongo-service-port-collision" have entirely different histories.
main
...
ci/fix-mon
52 changed files with 2174 additions and 5762 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,7 +2,6 @@
|
|||
.env.local
|
||||
.env.*.local
|
||||
.forgejo-token
|
||||
.zcode/
|
||||
node_modules/
|
||||
dist/
|
||||
target/
|
||||
|
|
|
|||
|
|
@ -35,39 +35,28 @@ pub fn build_app(state: AppState) -> Router {
|
|||
"/api/auth/recover-password",
|
||||
post(handlers::recover_password),
|
||||
)
|
||||
.route("/api/auth/recovery-info", get(handlers::recovery_info))
|
||||
// Recipient identity public key lookup (Phase B). Public keys are not
|
||||
// secret, so this is unauthenticated — mirrors recovery-info.
|
||||
.route("/api/users/public-key", get(handlers::get_public_key));
|
||||
.route("/api/auth/recovery-info", get(handlers::recovery_info));
|
||||
|
||||
// Build protected routes (auth required)
|
||||
let protected_routes = Router::new()
|
||||
// User profile management
|
||||
.route("/api/users/me", get(handlers::get_account))
|
||||
.route("/api/users/me", put(handlers::update_account))
|
||||
.route("/api/users/me", get(handlers::get_profile))
|
||||
.route("/api/users/me", put(handlers::update_profile))
|
||||
.route("/api/users/me", delete(handlers::delete_account))
|
||||
.route("/api/users/me/change-password", post(handlers::change_password))
|
||||
// User settings
|
||||
.route("/api/users/me/settings", get(handlers::get_settings))
|
||||
.route("/api/users/me/settings", put(handlers::update_settings))
|
||||
// Profile management (Phase A2 multi-profile + Phase B sharing).
|
||||
// GET /api/profiles/:id is share-aware (admits recipients); the other
|
||||
// profile ops remain owner-only.
|
||||
.route("/api/profiles", get(handlers::list_profiles))
|
||||
.route("/api/profiles", post(handlers::create_profile))
|
||||
.route("/api/profiles/shared-with-me", get(handlers::list_shared_with_me))
|
||||
.route("/api/profiles/:id", get(handlers::get_profile_shared_aware))
|
||||
.route("/api/profiles/:id", put(handlers::update_profile))
|
||||
.route("/api/profiles/:id", delete(handlers::delete_profile))
|
||||
// Profile sharing (Phase B): owner manages shares; recipients read via
|
||||
// the gate inside the data handlers.
|
||||
.route(
|
||||
"/api/profiles/:id/shares",
|
||||
get(handlers::list_shares).post(handlers::create_share),
|
||||
)
|
||||
.route("/api/profiles/:id/shares/:recipient", delete(handlers::delete_share))
|
||||
// Hard revoke / rotate the profile DEK (Phase C). Owner-only.
|
||||
.route("/api/profiles/:id/rekey", post(handlers::rekey_profile))
|
||||
// Share management
|
||||
.route("/api/shares", post(handlers::create_share))
|
||||
.route("/api/shares", get(handlers::list_shares))
|
||||
.route("/api/shares/:id", put(handlers::update_share))
|
||||
.route("/api/shares/:id", delete(handlers::delete_share))
|
||||
// Permission checking
|
||||
.route("/api/permissions/check", post(handlers::check_permission))
|
||||
// Profile management (Phase 3c)
|
||||
.route("/api/profiles/me", get(handlers::get_my_profile))
|
||||
.route("/api/profiles/me", put(handlers::update_my_profile))
|
||||
// Session management (Phase 2.6)
|
||||
.route("/api/sessions", get(handlers::get_sessions))
|
||||
.route("/api/sessions/:id", delete(handlers::revoke_session))
|
||||
|
|
|
|||
22
backend/src/auth/claims.rs
Normal file
22
backend/src/auth/claims.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AccessClaims {
|
||||
pub sub: String,
|
||||
pub email: String,
|
||||
pub family_id: Option<String>,
|
||||
pub permissions: Vec<String>,
|
||||
pub token_type: String,
|
||||
pub iat: i64,
|
||||
pub exp: i64,
|
||||
pub jti: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RefreshClaims {
|
||||
pub sub: String,
|
||||
pub token_type: String,
|
||||
pub iat: i64,
|
||||
pub exp: i64,
|
||||
pub jti: String,
|
||||
}
|
||||
|
|
@ -1,9 +1,6 @@
|
|||
use anyhow::Result;
|
||||
use pbkdf2::{
|
||||
password_hash::{
|
||||
rand_core::OsRng, Error as PasswordHashError, PasswordHash, PasswordHasher,
|
||||
PasswordVerifier, SaltString,
|
||||
},
|
||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Pbkdf2,
|
||||
};
|
||||
|
||||
|
|
@ -18,24 +15,14 @@ impl PasswordService {
|
|||
Ok(password_hash.to_string())
|
||||
}
|
||||
|
||||
/// Verify a password against a stored PHC string.
|
||||
///
|
||||
/// Returns `Ok(true)` on a match, `Ok(false)` on a mismatch (wrong
|
||||
/// password — an expected outcome, not an error), and `Err(...)` only for
|
||||
/// genuine failures (unparseable hash, malformed input, etc.). Returning
|
||||
/// `Ok(false)` for a wrong password lets the login handler distinguish
|
||||
/// "invalid credentials" (401) from a real backend fault (500).
|
||||
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
|
||||
let parsed_hash = PasswordHash::new(hash)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse password hash: {}", e))?;
|
||||
|
||||
match Pbkdf2.verify_password(password.as_bytes(), &parsed_hash) {
|
||||
Ok(()) => Ok(true),
|
||||
// `Error::Password` is the password-hash crate's signal for a
|
||||
// mismatch — the one expected "failure" during normal auth.
|
||||
Err(PasswordHashError::Password) => Ok(false),
|
||||
Err(e) => Err(anyhow::anyhow!("Password verification failed: {}", e)),
|
||||
}
|
||||
Pbkdf2
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.map(|_| true)
|
||||
.map_err(|e| anyhow::anyhow!("Password verification failed: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -98,30 +98,15 @@ impl DatabaseInitializer {
|
|||
println!("✓ Created appointments collection");
|
||||
}
|
||||
|
||||
// Create profile_shares collection and indexes (Phase B — replaces the
|
||||
// legacy shares collection). Lookups are by (profileId, recipientUserId)
|
||||
// and by recipientUserId alone (for /profiles/shared-with-me).
|
||||
// Create shares collection and index
|
||||
{
|
||||
let collection: Collection<mongodb::bson::Document> =
|
||||
self.db.collection("profile_shares");
|
||||
let collection: Collection<mongodb::bson::Document> = self.db.collection("shares");
|
||||
|
||||
let pair_index = IndexModel::builder()
|
||||
.keys(doc! { "profileId": 1, "recipientUserId": 1 })
|
||||
.build();
|
||||
match collection.create_index(pair_index, None).await {
|
||||
Ok(_) => println!("✓ Created index on profile_shares (profileId, recipientUserId)"),
|
||||
Err(e) => println!("Warning: Failed to create profile_shares pair index: {}", e),
|
||||
}
|
||||
let index = IndexModel::builder().keys(doc! { "familyId": 1 }).build();
|
||||
|
||||
let recipient_index = IndexModel::builder()
|
||||
.keys(doc! { "recipientUserId": 1 })
|
||||
.build();
|
||||
match collection.create_index(recipient_index, None).await {
|
||||
Ok(_) => println!("✓ Created index on profile_shares.recipientUserId"),
|
||||
Err(e) => println!(
|
||||
"Warning: Failed to create profile_shares recipient index: {}",
|
||||
e
|
||||
),
|
||||
match collection.create_index(index, None).await {
|
||||
Ok(_) => println!("✓ Created index on shares.familyId"),
|
||||
Err(e) => println!("Warning: Failed to create index on shares.familyId: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ use std::time::Duration;
|
|||
|
||||
use crate::models::{
|
||||
medication::{Medication, MedicationDose, MedicationRepository, UpdateMedicationRequest},
|
||||
permission::Permission,
|
||||
share::{Share, ShareRepository},
|
||||
user::{User, UserRepository},
|
||||
};
|
||||
|
||||
|
|
@ -12,6 +14,7 @@ use crate::models::{
|
|||
pub struct MongoDb {
|
||||
database: Database,
|
||||
pub users: Collection<User>,
|
||||
pub shares: Collection<Share>,
|
||||
pub medications: Collection<Medication>,
|
||||
pub medication_doses: Collection<MedicationDose>,
|
||||
}
|
||||
|
|
@ -59,6 +62,7 @@ impl MongoDb {
|
|||
|
||||
return Ok(Self {
|
||||
users: database.collection("users"),
|
||||
shares: database.collection("shares"),
|
||||
medications: database.collection("medications"),
|
||||
medication_doses: database.collection("medication_doses"),
|
||||
database,
|
||||
|
|
@ -93,6 +97,7 @@ impl MongoDb {
|
|||
|
||||
return Ok(Self {
|
||||
users: database.collection("users"),
|
||||
shares: database.collection("shares"),
|
||||
medications: database.collection("medications"),
|
||||
medication_doses: database.collection("medication_doses"),
|
||||
database,
|
||||
|
|
@ -108,6 +113,7 @@ impl MongoDb {
|
|||
|
||||
Ok(Self {
|
||||
users: database.collection("users"),
|
||||
shares: database.collection("shares"),
|
||||
medications: database.collection("medications"),
|
||||
medication_doses: database.collection("medication_doses"),
|
||||
database,
|
||||
|
|
@ -171,6 +177,100 @@ impl MongoDb {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ===== Share Methods =====
|
||||
|
||||
pub async fn create_share(&self, share: &Share) -> Result<Option<ObjectId>> {
|
||||
let repo = ShareRepository::new(self.shares.clone());
|
||||
Ok(repo.create(share).await?)
|
||||
}
|
||||
|
||||
pub async fn get_share(&self, id: &str) -> Result<Option<Share>> {
|
||||
let object_id = ObjectId::parse_str(id)?;
|
||||
let repo = ShareRepository::new(self.shares.clone());
|
||||
Ok(repo.find_by_id(&object_id).await?)
|
||||
}
|
||||
|
||||
pub async fn list_shares_for_user(&self, user_id: &str) -> Result<Vec<Share>> {
|
||||
let object_id = ObjectId::parse_str(user_id)?;
|
||||
let repo = ShareRepository::new(self.shares.clone());
|
||||
Ok(repo.find_by_target(&object_id).await?)
|
||||
}
|
||||
|
||||
pub async fn update_share(&self, share: &Share) -> Result<()> {
|
||||
let repo = ShareRepository::new(self.shares.clone());
|
||||
repo.update(share).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_share(&self, id: &str) -> Result<()> {
|
||||
let object_id = ObjectId::parse_str(id)?;
|
||||
let repo = ShareRepository::new(self.shares.clone());
|
||||
repo.delete(&object_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ===== Permission Methods =====
|
||||
|
||||
pub async fn check_user_permission(
|
||||
&self,
|
||||
user_id: &str,
|
||||
resource_type: &str,
|
||||
resource_id: &str,
|
||||
permission: &str,
|
||||
) -> Result<bool> {
|
||||
let user_oid = ObjectId::parse_str(user_id)?;
|
||||
let resource_oid = ObjectId::parse_str(resource_id)?;
|
||||
|
||||
let repo = ShareRepository::new(self.shares.clone());
|
||||
let shares = repo.find_by_target(&user_oid).await?;
|
||||
|
||||
for share in shares {
|
||||
if share.resource_type == resource_type
|
||||
&& share.resource_id.as_ref() == Some(&resource_oid)
|
||||
&& share.active
|
||||
&& !share.is_expired()
|
||||
{
|
||||
// Check if share has the required permission
|
||||
let perm = match permission.to_lowercase().as_str() {
|
||||
"read" => Permission::Read,
|
||||
"write" => Permission::Write,
|
||||
"delete" => Permission::Delete,
|
||||
"share" => Permission::Share,
|
||||
"admin" => Permission::Admin,
|
||||
_ => return Ok(false),
|
||||
};
|
||||
|
||||
if share.has_permission(&perm) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Check permission using a simplified interface
|
||||
pub async fn check_permission(
|
||||
&self,
|
||||
user_id: &str,
|
||||
resource_id: &str,
|
||||
permission: &str,
|
||||
) -> Result<bool> {
|
||||
// For now, check all resource types
|
||||
let resource_types = ["profiles", "health_data", "lab_results", "medications"];
|
||||
|
||||
for resource_type in resource_types {
|
||||
if self
|
||||
.check_user_permission(user_id, resource_type, resource_id, permission)
|
||||
.await?
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
// ===== Medication Methods (Fixed for Phase 2.8) =====
|
||||
|
||||
pub async fn create_medication(&self, medication: &Medication) -> Result<Option<ObjectId>> {
|
||||
|
|
|
|||
|
|
@ -20,8 +20,6 @@ use mongodb::bson::DateTime;
|
|||
pub struct AppointmentListQuery {
|
||||
/// Filter by status (upcoming/completed/cancelled). Optional.
|
||||
pub status: Option<String>,
|
||||
/// Filter by profile id. Optional.
|
||||
pub profile_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_appointment(
|
||||
|
|
@ -65,175 +63,64 @@ pub async fn list_appointments(
|
|||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Query(query): Query<AppointmentListQuery>,
|
||||
) -> Result<Json<Vec<AppointmentResponse>>, (StatusCode, Json<serde_json::Value>)> {
|
||||
) -> Result<Json<Vec<AppointmentResponse>>, StatusCode> {
|
||||
let database = state.db.get_database();
|
||||
let repo = AppointmentRepository::new(database.collection("appointments"));
|
||||
|
||||
// Resolve the effective owner. If a profile_id is specified, prefer direct
|
||||
// ownership (the caller's own data for that profile); otherwise fall through
|
||||
// to the share-gate (admits an active share recipient). profile_id is a
|
||||
// free-form string at create time and may not map to a Profile document.
|
||||
let owner = match &query.profile_id {
|
||||
Some(pid) => {
|
||||
let has_own = repo
|
||||
.find_by_user_filtered(&claims.sub, None, Some(pid))
|
||||
.await
|
||||
.map(|v| !v.is_empty())
|
||||
.unwrap_or(false);
|
||||
if has_own {
|
||||
claims.sub.clone()
|
||||
} else {
|
||||
crate::handlers::profile_share::authorize_profile_read(&state, &claims, pid).await?
|
||||
}
|
||||
}
|
||||
None => claims.sub.clone(),
|
||||
};
|
||||
|
||||
match repo
|
||||
.find_by_user_filtered(&owner, query.status.as_deref(), query.profile_id.as_deref())
|
||||
.find_by_user_filtered(&claims.sub, query.status.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(appointments) => {
|
||||
let resp: Vec<AppointmentResponse> = appointments.into_iter().map(Into::into).collect();
|
||||
Ok(Json(resp))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("list_appointments failed: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
))
|
||||
}
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_appointment(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Extension(_claims): Extension<Claims>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<AppointmentResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
) -> Result<Json<AppointmentResponse>, StatusCode> {
|
||||
let database = state.db.get_database();
|
||||
let repo = AppointmentRepository::new(database.collection("appointments"));
|
||||
|
||||
let appt = match repo.find_by_appointment_id(&id).await {
|
||||
Ok(Some(a)) => a,
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("get_appointment failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
// Authorization: direct owner (user_id == caller) OR share-gate on the
|
||||
// appointment's profile (admits an active share recipient).
|
||||
if appt.user_id != claims.sub {
|
||||
crate::handlers::profile_share::authorize_profile_read(&state, &claims, &appt.profile_id)
|
||||
.await?;
|
||||
match repo.find_by_appointment_id(&id).await {
|
||||
Ok(Some(appt)) => Ok(Json(appt.into())),
|
||||
Ok(None) => Err(StatusCode::NOT_FOUND),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
Ok(Json(appt.into()))
|
||||
}
|
||||
|
||||
pub async fn update_appointment(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Extension(_claims): Extension<Claims>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<UpdateAppointmentRequest>,
|
||||
) -> Result<Json<AppointmentResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
) -> Result<Json<AppointmentResponse>, StatusCode> {
|
||||
let database = state.db.get_database();
|
||||
let repo = AppointmentRepository::new(database.collection("appointments"));
|
||||
|
||||
// Authorization: owner-only (recipients are read-only). 404 on mismatch to
|
||||
// avoid leaking existence.
|
||||
let existing = match repo.find_by_appointment_id(&id).await {
|
||||
Ok(Some(a)) => a,
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("update_appointment lookup failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
if existing.user_id != claims.sub {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
));
|
||||
}
|
||||
|
||||
match repo.update_by_appointment_id(&id, req).await {
|
||||
Ok(Some(appt)) => Ok(Json(appt.into())),
|
||||
Ok(None) => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
)),
|
||||
Err(e) => {
|
||||
tracing::error!("update_appointment failed: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
))
|
||||
}
|
||||
Ok(None) => Err(StatusCode::NOT_FOUND),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_appointment(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Extension(_claims): Extension<Claims>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let database = state.db.get_database();
|
||||
let repo = AppointmentRepository::new(database.collection("appointments"));
|
||||
|
||||
// Authorization: owner-only.
|
||||
let existing = match repo.find_by_appointment_id(&id).await {
|
||||
Ok(Some(a)) => a,
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("delete_appointment lookup failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
if existing.user_id != claims.sub {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
));
|
||||
}
|
||||
|
||||
match repo.delete_by_appointment_id(&id).await {
|
||||
Ok(true) => Ok(StatusCode::NO_CONTENT),
|
||||
Ok(false) => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
)),
|
||||
Err(e) => {
|
||||
tracing::error!("delete_appointment failed: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
))
|
||||
}
|
||||
Ok(false) => Err(StatusCode::NOT_FOUND),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,20 +27,6 @@ pub struct RegisterRequest {
|
|||
/// Wrapped DEK (base64 ciphertext) — encrypted under the recovery KEK
|
||||
pub recovery_wrapped_dek: Option<String>,
|
||||
pub recovery_wrapped_dek_iv: Option<String>,
|
||||
/// Account X25519 identity public key (base64 raw). Plaintext.
|
||||
pub identity_public_key: Option<String>,
|
||||
/// Account X25519 identity private key, wrapped under the account DEK.
|
||||
pub identity_private_key_wrapped: Option<String>,
|
||||
pub identity_private_key_wrapped_iv: Option<String>,
|
||||
/// Default-profile display name (opaque, encrypted under the profile DEK)
|
||||
/// and the profile DEK wrapped under the account DEK. When present, the
|
||||
/// register flow auto-creates a "self" profile; when absent, no profile is
|
||||
/// created and the client is expected to POST /api/profiles on first run.
|
||||
pub default_profile_name_data: Option<String>,
|
||||
pub default_profile_name_iv: Option<String>,
|
||||
pub default_profile_name_auth_tag: Option<String>,
|
||||
pub default_wrapped_profile_dek: Option<String>,
|
||||
pub default_wrapped_profile_dek_iv: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
|
@ -54,12 +40,6 @@ pub struct AuthResponse {
|
|||
/// user has no wrapped DEK (legacy/pre-recovery accounts).
|
||||
pub wrapped_dek: Option<String>,
|
||||
pub wrapped_dek_iv: Option<String>,
|
||||
/// Account X25519 identity keypair. The public key is plaintext; the
|
||||
/// private key is an opaque ciphertext blob wrapped under the account DEK.
|
||||
/// Absent for accounts that predate the identity-keypair feature.
|
||||
pub identity_public_key: Option<String>,
|
||||
pub identity_private_key_wrapped: Option<String>,
|
||||
pub identity_private_key_wrapped_iv: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
|
|
@ -128,11 +108,6 @@ pub async fn register(
|
|||
user.recovery_wrapped_dek = req.recovery_wrapped_dek.clone();
|
||||
user.recovery_wrapped_dek_iv = req.recovery_wrapped_dek_iv.clone();
|
||||
user.recovery_enabled = req.recovery_wrapped_dek.is_some();
|
||||
// Store the X25519 identity keypair (public plaintext, private wrapped
|
||||
// under the account DEK — server stores verbatim, cannot decrypt).
|
||||
user.identity_public_key = req.identity_public_key;
|
||||
user.identity_private_key_wrapped = req.identity_private_key_wrapped;
|
||||
user.identity_private_key_wrapped_iv = req.identity_private_key_wrapped_iv;
|
||||
|
||||
// Get token_version before saving
|
||||
let token_version = user.token_version;
|
||||
|
|
@ -154,29 +129,17 @@ pub async fn register(
|
|||
.await;
|
||||
}
|
||||
|
||||
// Auto-create the default "self" profile for the new user, but only
|
||||
// when the client provided a wrapped profile DEK. The profile_id is
|
||||
// deterministic (profile_<owner>). Best-effort: registration still
|
||||
// succeeds if this fails or if no wrapped DEK was provided (the
|
||||
// client creates the profile via POST /api/profiles on first run).
|
||||
if let (Some(wrapped_dek), Some(wrapped_dek_iv)) = (
|
||||
req.default_wrapped_profile_dek.as_ref(),
|
||||
req.default_wrapped_profile_dek_iv.as_ref(),
|
||||
) {
|
||||
let database = state.db.get_database();
|
||||
let profile_repo =
|
||||
crate::models::profile::ProfileRepository::new(database.collection("profiles"));
|
||||
let profile = crate::handlers::profile::build_default_profile(
|
||||
&id.to_string(),
|
||||
wrapped_dek,
|
||||
wrapped_dek_iv,
|
||||
req.default_profile_name_data.as_deref().unwrap_or(""),
|
||||
req.default_profile_name_iv.as_deref().unwrap_or(""),
|
||||
req.default_profile_name_auth_tag.as_deref().unwrap_or(""),
|
||||
);
|
||||
if let Err(e) = profile_repo.create(&profile).await {
|
||||
tracing::warn!("Failed to auto-create profile for {}: {}", id, e);
|
||||
}
|
||||
// Auto-create a default profile for the new user. The profile_id is
|
||||
// deterministic (profile_<user_id>) and is the contract the frontend
|
||||
// uses for medication creation. Best-effort: registration still
|
||||
// succeeds if this fails (GET /profiles/me lazily creates one).
|
||||
let database = state.db.get_database();
|
||||
let profile_repo =
|
||||
crate::models::profile::ProfileRepository::new(database.collection("profiles"));
|
||||
let profile =
|
||||
crate::handlers::profile::build_default_profile(&id.to_string(), &req.username);
|
||||
if let Err(e) = profile_repo.create(&profile).await {
|
||||
tracing::warn!("Failed to auto-create profile for {}: {}", id, e);
|
||||
}
|
||||
|
||||
id
|
||||
|
|
@ -240,9 +203,6 @@ pub async fn register(
|
|||
username: user.username.clone(),
|
||||
wrapped_dek: user.wrapped_dek.clone(),
|
||||
wrapped_dek_iv: user.wrapped_dek_iv.clone(),
|
||||
identity_public_key: user.identity_public_key.clone(),
|
||||
identity_private_key_wrapped: user.identity_private_key_wrapped.clone(),
|
||||
identity_private_key_wrapped_iv: user.identity_private_key_wrapped_iv.clone(),
|
||||
};
|
||||
|
||||
(StatusCode::CREATED, Json(response)).into_response()
|
||||
|
|
@ -453,9 +413,6 @@ pub async fn login(
|
|||
username: user.username.clone(),
|
||||
wrapped_dek: user.wrapped_dek.clone(),
|
||||
wrapped_dek_iv: user.wrapped_dek_iv.clone(),
|
||||
identity_public_key: user.identity_public_key.clone(),
|
||||
identity_private_key_wrapped: user.identity_private_key_wrapped.clone(),
|
||||
identity_private_key_wrapped_iv: user.identity_private_key_wrapped_iv.clone(),
|
||||
};
|
||||
|
||||
(StatusCode::OK, Json(response)).into_response()
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use crate::config::AppState;
|
|||
use crate::models::health_stats::{HealthStatResponse, HealthStatistic};
|
||||
use crate::models::medication::EncryptedFieldWire;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
|
|
@ -14,16 +14,9 @@ use serde::Deserialize;
|
|||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateHealthStatRequest {
|
||||
pub encrypted_data: EncryptedFieldWire,
|
||||
/// Which profile this stat belongs to.
|
||||
pub profile_id: String,
|
||||
pub recorded_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct ListHealthStatsQuery {
|
||||
pub profile_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateHealthStatRequest {
|
||||
pub encrypted_data: EncryptedFieldWire,
|
||||
|
|
@ -48,7 +41,6 @@ pub async fn create_health_stat(
|
|||
let stat = HealthStatistic {
|
||||
id: None,
|
||||
user_id: claims.sub.clone(),
|
||||
profile_id: req.profile_id,
|
||||
encrypted_data: req.encrypted_data,
|
||||
recorded_at: req
|
||||
.recorded_at
|
||||
|
|
@ -78,7 +70,6 @@ pub async fn create_health_stat(
|
|||
pub async fn list_health_stats(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Query(query): Query<ListHealthStatsQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let repo = match state.health_stats_repo.as_ref() {
|
||||
Some(r) => r,
|
||||
|
|
@ -90,34 +81,7 @@ pub async fn list_health_stats(
|
|||
.into_response()
|
||||
}
|
||||
};
|
||||
// Resolve the effective owner. If a profile_id is specified, prefer direct
|
||||
// ownership (the caller's own data for that profile); otherwise fall through
|
||||
// to the share-gate (admits an active share recipient). profile_id is a
|
||||
// free-form string at create time and may not map to a Profile document.
|
||||
let owner = match &query.profile_id {
|
||||
Some(pid) => {
|
||||
let has_own = repo
|
||||
.find_by_user_filtered(&claims.sub, Some(pid))
|
||||
.await
|
||||
.map(|v| !v.is_empty())
|
||||
.unwrap_or(false);
|
||||
if has_own {
|
||||
claims.sub.clone()
|
||||
} else {
|
||||
match crate::handlers::profile_share::authorize_profile_read(&state, &claims, pid)
|
||||
.await
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(resp) => return resp.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
None => claims.sub.clone(),
|
||||
};
|
||||
match repo
|
||||
.find_by_user_filtered(&owner, query.profile_id.as_deref())
|
||||
.await
|
||||
{
|
||||
match repo.find_by_user(&claims.sub).await {
|
||||
Ok(stats) => {
|
||||
let resp: Vec<HealthStatResponse> = stats.into_iter().map(Into::into).collect();
|
||||
(StatusCode::OK, Json(resp)).into_response()
|
||||
|
|
@ -158,21 +122,10 @@ pub async fn get_health_stat(
|
|||
|
||||
match repo.find_by_id(&object_id).await {
|
||||
Ok(Some(stat)) => {
|
||||
// Authorization: direct owner (user_id == caller) OR share-gate on
|
||||
// the stat's profile (admits an active share recipient).
|
||||
if stat.user_id == claims.sub {
|
||||
return (StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response();
|
||||
}
|
||||
match crate::handlers::profile_share::authorize_profile_read(
|
||||
&state,
|
||||
&claims,
|
||||
&stat.profile_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => (StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response(),
|
||||
Err(resp) => resp.into_response(),
|
||||
if stat.user_id != claims.sub {
|
||||
return (StatusCode::FORBIDDEN, "Access denied").into_response();
|
||||
}
|
||||
(StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response()
|
||||
}
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
|
|
|
|||
|
|
@ -64,193 +64,70 @@ pub async fn list_medications(
|
|||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Query(query): Query<ListMedicationsQuery>,
|
||||
) -> Result<Json<Vec<MedicationResponse>>, (StatusCode, Json<serde_json::Value>)> {
|
||||
) -> Result<Json<Vec<MedicationResponse>>, StatusCode> {
|
||||
let database = state.db.get_database();
|
||||
let repo = MedicationRepository::new(database.collection("medications"));
|
||||
|
||||
// Resolve the effective owner. If the caller specifies a profile_id, prefer
|
||||
// direct ownership (the caller's own data for that profile); if they don't
|
||||
// own it, fall through to the share-gate (admits an active share recipient
|
||||
// and returns the profile's owner userId to query as). Without a profile_id,
|
||||
// return the caller's own data across all their profiles.
|
||||
//
|
||||
// The direct-ownership-first check matters because a medication's profile_id
|
||||
// is a free-form string at create time and may not map to a Profile document
|
||||
// (legacy/test data) — the gate would 404 on those even for the real owner.
|
||||
let owner = match &query.profile_id {
|
||||
Some(pid) => {
|
||||
let has_own = repo
|
||||
.find_by_user_and_profile(&claims.sub, pid)
|
||||
.await
|
||||
.map(|v| !v.is_empty())
|
||||
.unwrap_or(false);
|
||||
if has_own {
|
||||
claims.sub.clone()
|
||||
} else {
|
||||
crate::handlers::profile_share::authorize_profile_read(&state, &claims, pid).await?
|
||||
}
|
||||
}
|
||||
None => claims.sub.clone(),
|
||||
};
|
||||
|
||||
// Honor the active filter (and optional profile_id) when provided.
|
||||
match repo
|
||||
.find_by_user_filtered(&owner, query.active, query.profile_id.as_deref())
|
||||
.find_by_user_filtered(&claims.sub, query.active, query.profile_id.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(medications) => {
|
||||
let resp: Vec<MedicationResponse> = medications.into_iter().map(Into::into).collect();
|
||||
Ok(Json(resp))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("list_medications failed: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
))
|
||||
}
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_medication(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Extension(_claims): Extension<Claims>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<MedicationResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
) -> Result<Json<MedicationResponse>, StatusCode> {
|
||||
let database = state.db.get_database();
|
||||
let repo = MedicationRepository::new(database.collection("medications"));
|
||||
|
||||
// The path param is the application-level medication_id (a UUID), not the
|
||||
// Mongo _id, so look it up directly instead of parsing as ObjectId.
|
||||
let medication = match repo.find_by_medication_id(&id).await {
|
||||
Ok(Some(m)) => m,
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("get_medication failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
// Authorization: admit the direct owner (user_id == caller) OR, if not,
|
||||
// fall through to the share-gate on the medication's profile (admits an
|
||||
// active share recipient). The direct-owner check preserves the original
|
||||
// ownership model for medications whose profile_id may not map to a real
|
||||
// Profile document (e.g. legacy/test data).
|
||||
if medication.user_id != claims.sub {
|
||||
crate::handlers::profile_share::authorize_profile_read(
|
||||
&state,
|
||||
&claims,
|
||||
&medication.profile_id,
|
||||
)
|
||||
.await?;
|
||||
match repo.find_by_medication_id(&id).await {
|
||||
Ok(Some(medication)) => Ok(Json(medication.into())),
|
||||
Ok(None) => Err(StatusCode::NOT_FOUND),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
Ok(Json(medication.into()))
|
||||
}
|
||||
|
||||
pub async fn update_medication(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Extension(_claims): Extension<Claims>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<UpdateMedicationRequest>,
|
||||
) -> Result<Json<MedicationResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
) -> Result<Json<MedicationResponse>, StatusCode> {
|
||||
let database = state.db.get_database();
|
||||
let repo = MedicationRepository::new(database.collection("medications"));
|
||||
|
||||
// Authorization: look up first, confirm the caller owns the medication
|
||||
// (writes are owner-only — recipients are read-only per the ADR). Use 404
|
||||
// (not 403) on a mismatch to avoid leaking the existence of other users'
|
||||
// records.
|
||||
let existing = match repo.find_by_medication_id(&id).await {
|
||||
Ok(Some(m)) => m,
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("update_medication lookup failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
if existing.user_id != claims.sub {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
));
|
||||
}
|
||||
|
||||
// Look up by medication_id (UUID) — the path param is not a Mongo ObjectId.
|
||||
match repo.update_by_medication_id(&id, req).await {
|
||||
Ok(Some(medication)) => Ok(Json(medication.into())),
|
||||
Ok(None) => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
)),
|
||||
Err(e) => {
|
||||
tracing::error!("update_medication failed: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
))
|
||||
}
|
||||
Ok(None) => Err(StatusCode::NOT_FOUND),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_medication(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Extension(_claims): Extension<Claims>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let database = state.db.get_database();
|
||||
let repo = MedicationRepository::new(database.collection("medications"));
|
||||
|
||||
// Authorization: owner-only (see update_medication). Look up first to
|
||||
// avoid leaking existence via the deleted_count.
|
||||
let existing = match repo.find_by_medication_id(&id).await {
|
||||
Ok(Some(m)) => m,
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("delete_medication lookup failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
if existing.user_id != claims.sub {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
));
|
||||
}
|
||||
|
||||
// Look up by medication_id (UUID), not Mongo _id.
|
||||
match repo.delete_by_medication_id(&id).await {
|
||||
Ok(true) => Ok(StatusCode::NO_CONTENT),
|
||||
Ok(false) => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
)),
|
||||
Err(e) => {
|
||||
tracing::error!("delete_medication failed: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
))
|
||||
}
|
||||
Ok(false) => Err(StatusCode::NOT_FOUND),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -259,34 +136,8 @@ pub async fn log_dose(
|
|||
Extension(claims): Extension<Claims>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<LogDoseRequest>,
|
||||
) -> Result<(StatusCode, Json<MedicationDose>), (StatusCode, Json<serde_json::Value>)> {
|
||||
) -> Result<(StatusCode, Json<MedicationDose>), StatusCode> {
|
||||
let database = state.db.get_database();
|
||||
let repo = MedicationRepository::new(database.collection("medications"));
|
||||
|
||||
// Authorization: confirm the caller owns the medication before logging a
|
||||
// dose against it (otherwise a user could skew another's adherence stats).
|
||||
let med = match repo.find_by_medication_id(&id).await {
|
||||
Ok(Some(m)) => m,
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("log_dose lookup failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
if med.user_id != claims.sub {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
));
|
||||
}
|
||||
|
||||
let now = SystemTime::now();
|
||||
|
||||
|
|
@ -304,13 +155,7 @@ pub async fn log_dose(
|
|||
.collection::<crate::models::medication::MedicationDose>("medication_doses")
|
||||
.insert_one(&dose, None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("log_dose insert failed: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
)
|
||||
})?;
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Populate the generated _id so the caller gets the persisted dose back.
|
||||
dose.id = result.inserted_id.as_object_id();
|
||||
|
|
@ -320,10 +165,9 @@ pub async fn log_dose(
|
|||
|
||||
pub async fn get_adherence(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Extension(_claims): Extension<Claims>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<crate::models::medication::AdherenceStats>, (StatusCode, Json<serde_json::Value>)>
|
||||
{
|
||||
) -> Result<Json<crate::models::medication::AdherenceStats>, StatusCode> {
|
||||
use mongodb::bson::{doc, DateTime};
|
||||
|
||||
const PERIOD_DAYS: i64 = 30;
|
||||
|
|
@ -332,28 +176,6 @@ pub async fn get_adherence(
|
|||
let doses: mongodb::Collection<MedicationDose> = database.collection("medication_doses");
|
||||
let med_repo = MedicationRepository::new(database.collection("medications"));
|
||||
|
||||
// Authorization: owner-only. Look up the medication and confirm ownership
|
||||
// before computing/returning adherence (which leaks dose history).
|
||||
let medication = match med_repo.find_by_medication_id(&id).await {
|
||||
Ok(Some(m)) => {
|
||||
if m.user_id != claims.sub {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
));
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
tracing::error!("get_adherence lookup failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Look at doses logged in the last PERIOD_DAYS days for this medication.
|
||||
let since = DateTime::from_system_time(
|
||||
SystemTime::now() - std::time::Duration::from_secs(PERIOD_DAYS as u64 * 86400),
|
||||
|
|
@ -366,25 +188,19 @@ pub async fn get_adherence(
|
|||
let total_logged = doses
|
||||
.count_documents(filter.clone(), None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("get_adherence count failed: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
)
|
||||
})?;
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let taken_filter = doc! { "$and": [filter, doc! { "taken": true }] };
|
||||
let taken = doses
|
||||
.count_documents(taken_filter, None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("get_adherence taken count failed: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
)
|
||||
})?;
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Fetch the medication to check for a dose schedule.
|
||||
let medication = med_repo
|
||||
.find_by_medication_id(&id)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let (scheduled_doses, total_doses, missed_doses, rate) =
|
||||
match medication.and_then(|m| m.dose_schedule) {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ pub mod health;
|
|||
pub mod health_stats;
|
||||
pub mod interactions;
|
||||
pub mod medications;
|
||||
pub mod permissions;
|
||||
pub mod profile;
|
||||
pub mod profile_share;
|
||||
pub mod sessions;
|
||||
pub mod shares;
|
||||
pub mod users;
|
||||
|
||||
// Re-export commonly used handler functions
|
||||
|
|
@ -23,12 +24,10 @@ pub use medications::{
|
|||
create_medication, delete_medication, get_adherence, get_medication, list_medications,
|
||||
log_dose, update_medication,
|
||||
};
|
||||
pub use profile::{create_profile, delete_profile, get_profile, list_profiles, update_profile};
|
||||
pub use profile_share::{
|
||||
create_share, delete_share, get_profile_shared_aware, get_public_key, list_shared_with_me,
|
||||
list_shares, rekey_profile,
|
||||
};
|
||||
pub use permissions::check_permission;
|
||||
pub use profile::{get_my_profile, update_my_profile};
|
||||
pub use sessions::{get_sessions, revoke_all_sessions, revoke_session};
|
||||
pub use shares::{create_share, delete_share, list_shares, update_share};
|
||||
pub use users::{
|
||||
change_password, delete_account, get_account, get_settings, update_account, update_settings,
|
||||
change_password, delete_account, get_profile, get_settings, update_profile, update_settings,
|
||||
};
|
||||
|
|
|
|||
62
backend/src/handlers/permissions.rs
Normal file
62
backend/src/handlers/permissions.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{auth::jwt::Claims, config::AppState};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CheckPermissionQuery {
|
||||
pub resource_type: String,
|
||||
pub resource_id: String,
|
||||
pub permission: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PermissionCheckResponse {
|
||||
pub has_permission: bool,
|
||||
pub resource_type: String,
|
||||
pub resource_id: String,
|
||||
pub permission: String,
|
||||
}
|
||||
|
||||
pub async fn check_permission(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<CheckPermissionQuery>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
) -> impl IntoResponse {
|
||||
let has_permission = match state
|
||||
.db
|
||||
.check_user_permission(
|
||||
&claims.sub,
|
||||
¶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::{
|
||||
extract::{Extension, Path, State},
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
|
|
@ -12,21 +12,17 @@ use crate::{
|
|||
models::profile::{Profile, ProfileRepository},
|
||||
};
|
||||
|
||||
/// The profile as exposed to clients. Display name + wrapped profile DEK are
|
||||
/// returned as opaque client-encrypted blobs (the server cannot read either).
|
||||
/// The profile as exposed to clients. The display name is returned as an opaque
|
||||
/// client-encrypted blob (the server cannot read it).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ProfileResponse {
|
||||
pub profile_id: String,
|
||||
pub owner_account_id: String,
|
||||
pub user_id: String,
|
||||
/// Opaque client-encrypted display name.
|
||||
pub name_data: String,
|
||||
pub name_iv: String,
|
||||
pub kind: String,
|
||||
pub relationship: String,
|
||||
pub role: String,
|
||||
pub permissions: Vec<String>,
|
||||
/// Profile DEK wrapped under the owner's account DEK (opaque).
|
||||
pub wrapped_profile_dek: String,
|
||||
pub wrapped_profile_dek_iv: String,
|
||||
pub created_at: DateTime,
|
||||
pub updated_at: DateTime,
|
||||
}
|
||||
|
|
@ -35,212 +31,98 @@ impl From<Profile> for ProfileResponse {
|
|||
fn from(p: Profile) -> Self {
|
||||
Self {
|
||||
profile_id: p.profile_id,
|
||||
owner_account_id: p.owner_account_id,
|
||||
user_id: p.user_id,
|
||||
name_data: p.name,
|
||||
name_iv: p.name_iv,
|
||||
kind: p.kind,
|
||||
relationship: p.relationship,
|
||||
role: p.role,
|
||||
permissions: p.permissions,
|
||||
wrapped_profile_dek: p.wrapped_profile_dek,
|
||||
wrapped_profile_dek_iv: p.wrapped_profile_dek_iv,
|
||||
created_at: p.created_at,
|
||||
updated_at: p.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a default "self" profile for a freshly registered user. Called from
|
||||
/// `register` when the client provides a wrapped profile DEK for the default
|
||||
/// profile. The server has no encryption key, so the name blob and wrapped
|
||||
/// profile DEK are taken verbatim from the caller (the client generated the
|
||||
/// profile DEK and wrapped it under the account DEK).
|
||||
pub fn build_default_profile(
|
||||
owner_account_id: &str,
|
||||
wrapped_profile_dek: &str,
|
||||
wrapped_profile_dek_iv: &str,
|
||||
name_data: &str,
|
||||
name_iv: &str,
|
||||
name_auth_tag: &str,
|
||||
) -> Profile {
|
||||
/// Create the default "patient" profile for a freshly registered user. Called
|
||||
/// from `register`. The profile_id is deterministic: `profile_<user_id>` — this
|
||||
/// is the contract the frontend relies on for medication creation. The name is
|
||||
/// empty (not encrypted) because the server has no encryption key; the client
|
||||
/// sets it via PUT /profiles/me after deriving its key.
|
||||
pub fn build_default_profile(user_id: &str, _name: &str) -> Profile {
|
||||
let now = DateTime::now();
|
||||
Profile {
|
||||
id: None,
|
||||
// profile_<owner> keeps the deterministic-id contract the frontend
|
||||
// used pre-A2 for the self profile.
|
||||
profile_id: format!("profile_{owner_account_id}"),
|
||||
owner_account_id: owner_account_id.to_string(),
|
||||
user_id: owner_account_id.to_string(),
|
||||
profile_id: format!("profile_{user_id}"),
|
||||
user_id: user_id.to_string(),
|
||||
family_id: None,
|
||||
name: name_data.to_string(),
|
||||
name_iv: name_iv.to_string(),
|
||||
name_auth_tag: name_auth_tag.to_string(),
|
||||
kind: "human".to_string(),
|
||||
relationship: "self".to_string(),
|
||||
name: String::new(),
|
||||
name_iv: String::new(),
|
||||
name_auth_tag: String::new(),
|
||||
role: "patient".to_string(),
|
||||
permissions: vec!["read:self".to_string(), "write:self".to_string()],
|
||||
wrapped_profile_dek: wrapped_profile_dek.to_string(),
|
||||
wrapped_profile_dek_iv: wrapped_profile_dek_iv.to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_repo(state: &AppState) -> ProfileRepository {
|
||||
ProfileRepository::new(state.db.get_database().collection("profiles"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/profiles — list all profiles owned by the current account
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn list_profiles(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
) -> Result<Json<Vec<ProfileResponse>>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let repo = profile_repo(&state);
|
||||
match repo.find_all_by_owner(&claims.sub).await {
|
||||
Ok(profiles) => {
|
||||
let resp: Vec<ProfileResponse> = profiles.into_iter().map(Into::into).collect();
|
||||
Ok(Json(resp))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Profile list failed: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/profiles — create a new profile (child, pet, ...).
|
||||
// The client generates the profile DEK, wraps it under the account DEK, and
|
||||
// sends the wrapped blob + the encrypted display name.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// PUT /profiles/me body — the client sends the encrypted name blob.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateProfileRequest {
|
||||
/// Optional explicit profile id; defaults to a fresh UUID if absent.
|
||||
#[serde(default)]
|
||||
pub profile_id: Option<String>,
|
||||
#[serde(default = "default_kind_value")]
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub relationship: String,
|
||||
pub struct UpdateProfileNameRequest {
|
||||
pub name_data: String,
|
||||
pub name_iv: String,
|
||||
#[serde(default)]
|
||||
pub name_auth_tag: String,
|
||||
/// Profile DEK wrapped under the account DEK (opaque ciphertext).
|
||||
pub wrapped_profile_dek: String,
|
||||
pub wrapped_profile_dek_iv: String,
|
||||
}
|
||||
|
||||
fn default_kind_value() -> String {
|
||||
"human".to_string()
|
||||
}
|
||||
|
||||
pub async fn create_profile(
|
||||
/// GET /api/profiles/me — the current user's profile. If for some reason the
|
||||
/// auto-created profile is missing, lazily create it.
|
||||
pub async fn get_my_profile(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Json(req): Json<CreateProfileRequest>,
|
||||
) -> Result<(StatusCode, Json<ProfileResponse>), (StatusCode, Json<serde_json::Value>)> {
|
||||
let now = DateTime::now();
|
||||
let profile = Profile {
|
||||
id: None,
|
||||
profile_id: req
|
||||
.profile_id
|
||||
.unwrap_or_else(|| format!("p_{}", uuid::Uuid::new_v4())),
|
||||
owner_account_id: claims.sub.clone(),
|
||||
user_id: claims.sub.clone(),
|
||||
family_id: None,
|
||||
name: req.name_data,
|
||||
name_iv: req.name_iv,
|
||||
name_auth_tag: req.name_auth_tag,
|
||||
kind: req.kind,
|
||||
relationship: req.relationship,
|
||||
role: "patient".to_string(),
|
||||
permissions: vec!["read:self".to_string(), "write:self".to_string()],
|
||||
wrapped_profile_dek: req.wrapped_profile_dek,
|
||||
wrapped_profile_dek_iv: req.wrapped_profile_dek_iv,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
let repo = profile_repo(&state);
|
||||
if let Err(e) = repo.create(&profile).await {
|
||||
tracing::error!("Profile create failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
Ok((StatusCode::CREATED, Json(ProfileResponse::from(profile))))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/profiles/:id — fetch one owned profile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_profile(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Path(profile_id): Path<String>,
|
||||
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let repo = profile_repo(&state);
|
||||
match repo
|
||||
.find_by_profile_id_owned(&profile_id, &claims.sub)
|
||||
.await
|
||||
{
|
||||
Ok(Some(p)) => Ok(Json(ProfileResponse::from(p))),
|
||||
Ok(None) => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "profile not found" })),
|
||||
)),
|
||||
let database = state.db.get_database();
|
||||
let repo = ProfileRepository::new(database.collection("profiles"));
|
||||
|
||||
let profile = match repo.find_by_user_id(&claims.sub).await {
|
||||
Ok(Some(p)) => p,
|
||||
Ok(None) => {
|
||||
// Lazily create if missing (e.g. users registered before this code shipped).
|
||||
let p = build_default_profile(&claims.sub, &claims.sub);
|
||||
if let Err(e) = repo.create(&p).await {
|
||||
tracing::error!("Failed to lazily create profile: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "failed to load profile" })),
|
||||
));
|
||||
}
|
||||
p
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Profile lookup failed: {}", e);
|
||||
Err((
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
))
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(ProfileResponse::from(profile)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PUT /api/profiles/:id — update an owned profile's mutable fields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateProfileRequest {
|
||||
pub name_data: String,
|
||||
pub name_iv: String,
|
||||
#[serde(default)]
|
||||
pub name_auth_tag: String,
|
||||
#[serde(default = "default_kind_value")]
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub relationship: String,
|
||||
}
|
||||
|
||||
pub async fn update_profile(
|
||||
/// PUT /api/profiles/me — update the profile's encrypted display-name blob.
|
||||
pub async fn update_my_profile(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Path(profile_id): Path<String>,
|
||||
Json(req): Json<UpdateProfileRequest>,
|
||||
Json(req): Json<UpdateProfileNameRequest>,
|
||||
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let repo = profile_repo(&state);
|
||||
let database = state.db.get_database();
|
||||
let repo = ProfileRepository::new(database.collection("profiles"));
|
||||
|
||||
match repo
|
||||
.update_profile(
|
||||
&profile_id,
|
||||
.update_encrypted_name(
|
||||
&claims.sub,
|
||||
&req.name_data,
|
||||
&req.name_iv,
|
||||
&req.name_auth_tag,
|
||||
&req.kind,
|
||||
&req.relationship,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
@ -258,29 +140,3 @@ pub async fn update_profile(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /api/profiles/:id — delete an owned profile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn delete_profile(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Path(profile_id): Path<String>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
|
||||
let repo = profile_repo(&state);
|
||||
match repo.delete_profile(&profile_id, &claims.sub).await {
|
||||
Ok(true) => Ok(StatusCode::NO_CONTENT),
|
||||
Ok(false) => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "profile not found" })),
|
||||
)),
|
||||
Err(e) => {
|
||||
tracing::error!("Profile delete failed: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,708 +0,0 @@
|
|||
//! Profile sharing (Phase B).
|
||||
//!
|
||||
//! Implements the X25519 envelope: an owner wraps a profile DEK to a
|
||||
//! recipient's identity public key via ECDH, storing the opaque wrapped blob
|
||||
//! keyed by (profileId, recipientUserId). Recipients unwrap with their own
|
||||
//! identity private key. The server never sees the profile DEK.
|
||||
//!
|
||||
//! Also provides `authorize_profile_read` — the share-gate used by the data
|
||||
//! handlers (medications, appointments, health stats) to admit recipients.
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use mongodb::bson::{oid::ObjectId, DateTime};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
auth::jwt::Claims,
|
||||
config::AppState,
|
||||
models::profile::ProfileRepository,
|
||||
models::profile_share::{ProfileShare, ProfileShareRepository},
|
||||
};
|
||||
|
||||
fn profile_repo(state: &AppState) -> ProfileRepository {
|
||||
ProfileRepository::new(state.db.get_database().collection("profiles"))
|
||||
}
|
||||
|
||||
fn share_repo(state: &AppState) -> ProfileShareRepository {
|
||||
ProfileShareRepository::new(state.db.get_database().collection("profile_shares"))
|
||||
}
|
||||
|
||||
/// The shared profile as seen by the recipient — the profile's metadata
|
||||
/// (display name blob, kind, relationship) plus the per-share ECDH-wrapped DEK
|
||||
/// the recipient unwraps with their identity private key. The server returns
|
||||
/// the wrapped DEK from the share record verbatim and cannot read it.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SharedProfileResponse {
|
||||
pub profile_id: String,
|
||||
pub owner_account_id: String,
|
||||
pub name_data: String,
|
||||
pub name_iv: String,
|
||||
pub kind: String,
|
||||
pub relationship: String,
|
||||
/// Per-share ephemeral X25519 public key (base64 raw). The recipient
|
||||
/// combines it with their identity private key to derive the wrap key.
|
||||
pub ephemeral_public_key: String,
|
||||
/// Profile DEK wrapped under the ECDH-derived key (opaque).
|
||||
pub wrapped_profile_dek: String,
|
||||
pub wrapped_profile_dek_iv: String,
|
||||
pub permissions: Vec<String>,
|
||||
pub created_at: DateTime,
|
||||
}
|
||||
|
||||
/// A share as listed by the owner (no wrapped DEK on this view — it's only
|
||||
/// useful to the recipient; the owner already has the profile DEK directly).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ShareListingResponse {
|
||||
pub profile_id: String,
|
||||
pub recipient_user_id: String,
|
||||
pub recipient_email: String,
|
||||
pub permissions: Vec<String>,
|
||||
pub created_at: DateTime,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Share-gate: resolve the owner userId for a profile the caller may read.
|
||||
// Used by the data handlers to admit recipients.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// If the caller (claims.sub) owns the profile, returns Ok(owner_user_id).
|
||||
/// Otherwise checks for an active share; if present, returns Ok(owner_user_id)
|
||||
/// (the profile's owner — recipients read "as the owner" for that profile).
|
||||
/// Returns Err(404 response) if the caller has no read access.
|
||||
pub async fn authorize_profile_read(
|
||||
state: &AppState,
|
||||
claims: &Claims,
|
||||
profile_id: &str,
|
||||
) -> Result<String, (StatusCode, Json<serde_json::Value>)> {
|
||||
let profiles = profile_repo(state);
|
||||
// Owner path.
|
||||
if let Ok(Some(owned)) = profiles
|
||||
.find_by_profile_id_owned(profile_id, &claims.sub)
|
||||
.await
|
||||
{
|
||||
return Ok(owned.owner_account_id);
|
||||
}
|
||||
// Recipient path: an active (unexpired, not-revoked) share for this pair.
|
||||
let shares = share_repo(state);
|
||||
match shares.find_active(profile_id, &claims.sub).await {
|
||||
Ok(Some(share)) => Ok(share.owner_user_id),
|
||||
Ok(None) => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "profile not found" })),
|
||||
)),
|
||||
Err(e) => {
|
||||
tracing::error!("Share lookup failed for {}: {}", profile_id, e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/profiles/:id/shares — owner shares a profile to a recipient.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateShareRequest {
|
||||
/// The recipient, identified by email.
|
||||
pub recipient_email: String,
|
||||
/// Ephemeral X25519 public key (base64 raw) generated by the owner's
|
||||
/// client for this share.
|
||||
pub ephemeral_public_key: String,
|
||||
/// Profile DEK wrapped (client-side, via ECDH) to the recipient. Opaque.
|
||||
pub wrapped_profile_dek: String,
|
||||
pub wrapped_profile_dek_iv: String,
|
||||
#[serde(default = "default_read_permissions")]
|
||||
pub permissions: Vec<String>,
|
||||
/// Optional RFC-3339 expiry; null/absent = never expires.
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
fn default_read_permissions() -> Vec<String> {
|
||||
vec!["read".to_string()]
|
||||
}
|
||||
|
||||
pub async fn create_share(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Path(profile_id): Path<String>,
|
||||
Json(req): Json<CreateShareRequest>,
|
||||
) -> Result<(StatusCode, Json<ShareListingResponse>), (StatusCode, Json<serde_json::Value>)> {
|
||||
// Verify the caller owns the profile.
|
||||
let profiles = profile_repo(&state);
|
||||
let owner = match profiles
|
||||
.find_by_profile_id_owned(&profile_id, &claims.sub)
|
||||
.await
|
||||
{
|
||||
Ok(Some(p)) => p.owner_account_id,
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "profile not found" })),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Profile lookup in create_share failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve recipient by email.
|
||||
let recipient = match state.db.find_user_by_email(&req.recipient_email).await {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "recipient not found" })),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Recipient lookup failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
let recipient_id = match recipient.id {
|
||||
Some(id) => id.to_string(),
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "invalid recipient state" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
// Can't share to an account without an identity public key (pre-A1).
|
||||
if recipient
|
||||
.identity_public_key
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.is_empty()
|
||||
{
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": "recipient has no identity public key (account predates the keypair feature)"
|
||||
})),
|
||||
));
|
||||
}
|
||||
// Don't share to yourself.
|
||||
if recipient_id == claims.sub {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": "cannot share a profile with yourself" })),
|
||||
));
|
||||
}
|
||||
|
||||
let expires_at = match req.expires_at.as_deref() {
|
||||
Some(s) => match DateTime::parse_rfc3339_str(s) {
|
||||
Ok(dt) => Some(dt),
|
||||
Err(_) => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": "invalid expires_at (expected RFC 3339)" })),
|
||||
));
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
let now = DateTime::now();
|
||||
let share = ProfileShare {
|
||||
id: None,
|
||||
profile_id: profile_id.clone(),
|
||||
owner_user_id: owner.clone(),
|
||||
recipient_user_id: recipient_id.clone(),
|
||||
ephemeral_public_key: req.ephemeral_public_key,
|
||||
wrapped_profile_dek: req.wrapped_profile_dek,
|
||||
wrapped_profile_dek_iv: req.wrapped_profile_dek_iv,
|
||||
permissions: req.permissions.clone(),
|
||||
expires_at,
|
||||
created_at: now,
|
||||
active: true,
|
||||
};
|
||||
|
||||
let shares = share_repo(&state);
|
||||
if let Err(e) = shares.upsert(&share).await {
|
||||
tracing::error!("Share create failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(ShareListingResponse {
|
||||
profile_id,
|
||||
recipient_user_id: recipient_id,
|
||||
recipient_email: req.recipient_email,
|
||||
permissions: req.permissions,
|
||||
created_at: now,
|
||||
active: true,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/profiles/:id/shares — owner lists who they've shared a profile with.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn list_shares(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Path(profile_id): Path<String>,
|
||||
) -> Result<Json<Vec<ShareListingResponse>>, (StatusCode, Json<serde_json::Value>)> {
|
||||
// Owner-only: confirm ownership.
|
||||
let profiles = profile_repo(&state);
|
||||
match profiles
|
||||
.find_by_profile_id_owned(&profile_id, &claims.sub)
|
||||
.await
|
||||
{
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "profile not found" })),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Profile lookup in list_shares failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let shares = share_repo(&state);
|
||||
let rows = match shares.find_for_profile(&profile_id).await {
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
tracing::error!("Share list failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve each recipient's email for display.
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for s in rows {
|
||||
let email = match ObjectId::parse_str(&s.recipient_user_id) {
|
||||
Ok(oid) => match state.db.find_user_by_id(&oid).await {
|
||||
Ok(Some(u)) => u.email,
|
||||
_ => String::new(),
|
||||
},
|
||||
Err(_) => String::new(),
|
||||
};
|
||||
out.push(ShareListingResponse {
|
||||
profile_id: s.profile_id,
|
||||
recipient_user_id: s.recipient_user_id,
|
||||
recipient_email: email,
|
||||
permissions: s.permissions,
|
||||
created_at: s.created_at,
|
||||
active: s.active,
|
||||
});
|
||||
}
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /api/profiles/:id/shares/:recipient — soft revoke (hard delete).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn delete_share(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Path((profile_id, recipient_user_id)): Path<(String, String)>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
|
||||
// Owner-only: confirm ownership.
|
||||
let profiles = profile_repo(&state);
|
||||
match profiles
|
||||
.find_by_profile_id_owned(&profile_id, &claims.sub)
|
||||
.await
|
||||
{
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "profile not found" })),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Profile lookup in delete_share failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let shares = share_repo(&state);
|
||||
match shares.delete(&profile_id, &recipient_user_id).await {
|
||||
Ok(true) => Ok(StatusCode::NO_CONTENT),
|
||||
Ok(false) => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "share not found" })),
|
||||
)),
|
||||
Err(e) => {
|
||||
tracing::error!("Share delete failed: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/profiles/shared-with-me — recipient lists profiles shared with them.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn list_shared_with_me(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
) -> Result<Json<Vec<SharedProfileResponse>>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let shares = share_repo(&state);
|
||||
let my_shares = match shares.find_for_recipient(&claims.sub).await {
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
tracing::error!("shared-with-me list failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Join each share with its profile's metadata. Only return shares for
|
||||
// profiles that still exist and are still active/unexpired (find_active
|
||||
// semantics, re-checked here to avoid listing a just-expired share).
|
||||
let profiles = profile_repo(&state);
|
||||
let mut out = Vec::with_capacity(my_shares.len());
|
||||
for s in my_shares {
|
||||
// Skip expired/inactive — find_for_recipient returns all rows; filter
|
||||
// to currently-usable ones for the recipient's view.
|
||||
if !s.active {
|
||||
continue;
|
||||
}
|
||||
if let Some(exp) = s.expires_at {
|
||||
if exp <= DateTime::now() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let profile = match profiles.find_by_profile_id(&s.profile_id).await {
|
||||
Ok(Some(p)) => p,
|
||||
_ => continue, // profile deleted; skip silently
|
||||
};
|
||||
out.push(SharedProfileResponse {
|
||||
profile_id: profile.profile_id,
|
||||
owner_account_id: profile.owner_account_id,
|
||||
name_data: profile.name,
|
||||
name_iv: profile.name_iv,
|
||||
kind: profile.kind,
|
||||
relationship: profile.relationship,
|
||||
ephemeral_public_key: s.ephemeral_public_key,
|
||||
wrapped_profile_dek: s.wrapped_profile_dek,
|
||||
wrapped_profile_dek_iv: s.wrapped_profile_dek_iv,
|
||||
permissions: s.permissions,
|
||||
created_at: s.created_at,
|
||||
});
|
||||
}
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/profiles/:id — extend the existing profile GET to admit recipients.
|
||||
// This stands in for "the recipient can fetch one shared profile's metadata".
|
||||
// Implemented here so the share-aware logic lives with the share code.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fetch a single profile's metadata, admitting both owner and share-recipient.
|
||||
/// Returns the owner's-view `ProfileResponse` shape (the wrapped profile DEK
|
||||
/// there is account-wrapped — recipients ignore it and use the share's
|
||||
/// ECDH-wrapped DEK from /profiles/shared-with-me instead).
|
||||
pub async fn get_profile_shared_aware(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Path(profile_id): Path<String>,
|
||||
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
// authorize_profile_read admits owner or active-share recipient.
|
||||
let _owner = authorize_profile_read(&state, &claims, &profile_id).await?;
|
||||
let profiles = profile_repo(&state);
|
||||
match profiles.find_by_profile_id(&profile_id).await {
|
||||
Ok(Some(p)) => Ok(Json(ProfileResponse::from(p))),
|
||||
Ok(None) => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "profile not found" })),
|
||||
)),
|
||||
Err(e) => {
|
||||
tracing::error!("Profile fetch failed: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export ProfileResponse from the profile handlers for the shared-aware GET.
|
||||
pub use crate::handlers::profile::ProfileResponse;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/users/public-key — look up a recipient's identity public key.
|
||||
// Public (no auth) — public keys are not secret.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PublicKeyQuery {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PublicKeyResponse {
|
||||
pub user_id: String,
|
||||
pub identity_public_key: String,
|
||||
}
|
||||
|
||||
pub async fn get_public_key(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<PublicKeyQuery>,
|
||||
) -> Result<Json<PublicKeyResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let user = match state.db.find_user_by_email(&q.email).await {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) | Err(_) => {
|
||||
// Don't leak whether the email exists.
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "no public key for this address" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
let user_id = user
|
||||
.id
|
||||
.ok_or((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "invalid user state" })),
|
||||
))?
|
||||
.to_string();
|
||||
let pk = user.identity_public_key.unwrap_or_default();
|
||||
if pk.is_empty() {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "no public key for this address" })),
|
||||
));
|
||||
}
|
||||
Ok(Json(PublicKeyResponse {
|
||||
user_id,
|
||||
identity_public_key: pk,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/profiles/:id/rekey — hard revoke / rotate the profile DEK (Phase C).
|
||||
//
|
||||
// The owner's client has already (a) generated a fresh profile DEK, (b)
|
||||
// re-encrypted all the profile's data under it (client-side), and (c) wrapped
|
||||
// the new DEK to its own account DEK + to each still-valid recipient's identity
|
||||
// public key via fresh ECDH envelopes. This call commits the rotation: it
|
||||
// stores the new owner-wrapped DEK, upserts each submitted recipient envelope,
|
||||
// and hard-deletes any current recipient NOT in the submitted list (the
|
||||
// hard-revoke). The server stores only opaque blobs + public keys.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RecipientEnvelope {
|
||||
pub recipient_user_id: String,
|
||||
pub ephemeral_public_key: String,
|
||||
pub wrapped_profile_dek: String,
|
||||
pub wrapped_profile_dek_iv: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RekeyRequest {
|
||||
/// The NEW profile DEK, wrapped under the owner's account DEK. Opaque.
|
||||
pub new_wrapped_profile_dek: String,
|
||||
pub new_wrapped_profile_dek_iv: String,
|
||||
/// One fresh ECDH envelope per recipient the owner wants to KEEP. Any
|
||||
/// current recipient not listed here is hard-revoked.
|
||||
#[serde(default)]
|
||||
pub recipient_envelopes: Vec<RecipientEnvelope>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RekeyResponse {
|
||||
pub profile_id: String,
|
||||
pub wrapped_profile_dek: String,
|
||||
pub wrapped_profile_dek_iv: String,
|
||||
/// The recipients still sharing this profile after the rotation.
|
||||
pub retained_recipient_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn rekey_profile(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Path(profile_id): Path<String>,
|
||||
Json(req): Json<RekeyRequest>,
|
||||
) -> Result<(StatusCode, Json<RekeyResponse>), (StatusCode, Json<serde_json::Value>)> {
|
||||
// 1. Owner-only. (Admin-tier rekey is reserved for a later phase; the
|
||||
// handler is owner-only for now.)
|
||||
let profiles = profile_repo(&state);
|
||||
let owner = match profiles
|
||||
.find_by_profile_id_owned(&profile_id, &claims.sub)
|
||||
.await
|
||||
{
|
||||
Ok(Some(p)) => p.owner_account_id,
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "profile not found" })),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("rekey profile lookup failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Validate each submitted envelope: the owner can only rotate envelopes
|
||||
// for recipients who ALREADY have an active share. (Adding a new share
|
||||
// goes through POST /profiles/:id/shares, not rekey.) This prevents
|
||||
// smuggling in a brand-new recipient via the rekey call.
|
||||
let shares = share_repo(&state);
|
||||
let active_shares = match shares.find_active_for_profile(&profile_id).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::error!("rekey active-share lookup failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
let active_recipient_ids: std::collections::HashSet<&str> = active_shares
|
||||
.iter()
|
||||
.map(|s| s.recipient_user_id.as_str())
|
||||
.collect();
|
||||
for env in &req.recipient_envelopes {
|
||||
if !active_recipient_ids.contains(env.recipient_user_id.as_str()) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "recipient_envelope references a recipient with no active share; use POST /profiles/:id/shares to add a share",
|
||||
"recipient_user_id": env.recipient_user_id,
|
||||
})),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Rotate the owner share (store the new account-wrapped profile DEK).
|
||||
// update_wrapped_dek uses find_one_and_update, which returns the PRE-image
|
||||
// by default — so we don't read the new values back from it; we echo the
|
||||
// request values (exactly what was stored) in the response below.
|
||||
match profiles
|
||||
.update_wrapped_dek(
|
||||
&profile_id,
|
||||
&owner,
|
||||
&req.new_wrapped_profile_dek,
|
||||
&req.new_wrapped_profile_dek_iv,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "profile not found" })),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("rekey owner-share rotation failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Upsert each submitted recipient envelope (fresh ECDH-wrapped DEK +
|
||||
// ephemeral pubkey, replacing the old envelope). Preserve the original
|
||||
// share's permissions / expires_at by reading them from the active share.
|
||||
let now = DateTime::now();
|
||||
for env in &req.recipient_envelopes {
|
||||
let original = active_shares
|
||||
.iter()
|
||||
.find(|s| s.recipient_user_id == env.recipient_user_id);
|
||||
let permissions = original
|
||||
.map(|s| s.permissions.clone())
|
||||
.unwrap_or_else(|| vec!["read".to_string()]);
|
||||
let expires_at = original.and_then(|s| s.expires_at);
|
||||
let new_share = ProfileShare {
|
||||
id: None,
|
||||
profile_id: profile_id.clone(),
|
||||
owner_user_id: owner.clone(),
|
||||
recipient_user_id: env.recipient_user_id.clone(),
|
||||
ephemeral_public_key: env.ephemeral_public_key.clone(),
|
||||
wrapped_profile_dek: env.wrapped_profile_dek.clone(),
|
||||
wrapped_profile_dek_iv: env.wrapped_profile_dek_iv.clone(),
|
||||
permissions,
|
||||
expires_at,
|
||||
created_at: now,
|
||||
active: true,
|
||||
};
|
||||
if let Err(e) = shares.upsert(&new_share).await {
|
||||
tracing::error!("rekey recipient upsert failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Hard-revoke any current recipient NOT in the submitted list. Their
|
||||
// cached old DEK no longer matches the rotated profile DEK, AND the
|
||||
// server stops serving them.
|
||||
let keep: Vec<String> = req
|
||||
.recipient_envelopes
|
||||
.iter()
|
||||
.map(|e| e.recipient_user_id.clone())
|
||||
.collect();
|
||||
if let Err(e) = shares
|
||||
.delete_for_profile_excluding(&profile_id, &keep)
|
||||
.await
|
||||
{
|
||||
tracing::error!("rekey hard-revoke delete failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(RekeyResponse {
|
||||
profile_id,
|
||||
// Echo the request values (exactly what was stored). Avoids the
|
||||
// find_one_and_update pre-image gotcha.
|
||||
wrapped_profile_dek: req.new_wrapped_profile_dek,
|
||||
wrapped_profile_dek_iv: req.new_wrapped_profile_dek_iv,
|
||||
retained_recipient_user_ids: keep,
|
||||
}),
|
||||
))
|
||||
}
|
||||
453
backend/src/handlers/shares.rs
Normal file
453
backend/src/handlers/shares.rs
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use mongodb::bson::oid::ObjectId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
auth::jwt::Claims,
|
||||
config::AppState,
|
||||
models::{permission::Permission, share::Share},
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct CreateShareRequest {
|
||||
pub target_user_email: String,
|
||||
pub resource_type: String,
|
||||
pub resource_id: Option<String>,
|
||||
pub permissions: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub expires_days: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ShareResponse {
|
||||
pub id: String,
|
||||
pub target_user_id: String,
|
||||
pub resource_type: String,
|
||||
pub resource_id: Option<String>,
|
||||
pub permissions: Vec<String>,
|
||||
pub expires_at: Option<i64>,
|
||||
pub created_at: i64,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
impl TryFrom<Share> for ShareResponse {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(share: Share) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
id: share.id.map(|id| id.to_string()).unwrap_or_default(),
|
||||
target_user_id: share.target_user_id.to_string(),
|
||||
resource_type: share.resource_type,
|
||||
resource_id: share.resource_id.map(|id| id.to_string()),
|
||||
permissions: share
|
||||
.permissions
|
||||
.into_iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect(),
|
||||
expires_at: share.expires_at.map(|dt| dt.timestamp_millis()),
|
||||
created_at: share.created_at.timestamp_millis(),
|
||||
active: share.active,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_share(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Json(req): Json<CreateShareRequest>,
|
||||
) -> impl IntoResponse {
|
||||
if let Err(errors) = req.validate() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "validation failed",
|
||||
"details": errors.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Find target user by email
|
||||
let target_user = match state.db.find_user_by_email(&req.target_user_email).await {
|
||||
Ok(Some(user)) => user,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": "target user not found"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to find target user: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "database error"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let target_user_id = match target_user.id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "target user has no ID"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let owner_id = match ObjectId::parse_str(&claims.sub) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid user ID format"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Parse resource_id if provided
|
||||
let resource_id = match req.resource_id {
|
||||
Some(id) => match ObjectId::parse_str(&id) {
|
||||
Ok(oid) => Some(oid),
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid resource_id format"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Parse permissions - support all permission types
|
||||
let permissions: Vec<Permission> = req
|
||||
.permissions
|
||||
.into_iter()
|
||||
.filter_map(|p| match p.to_lowercase().as_str() {
|
||||
"read" => Some(Permission::Read),
|
||||
"write" => Some(Permission::Write),
|
||||
"delete" => Some(Permission::Delete),
|
||||
"share" => Some(Permission::Share),
|
||||
"admin" => Some(Permission::Admin),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if permissions.is_empty() {
|
||||
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
|
||||
"error": "at least one valid permission is required (read, write, delete, share, admin)"
|
||||
}))).into_response();
|
||||
}
|
||||
|
||||
// Calculate expiration
|
||||
let expires_at = req.expires_days.map(|days| {
|
||||
mongodb::bson::DateTime::now().saturating_add_millis(days as i64 * 24 * 60 * 60 * 1000)
|
||||
});
|
||||
|
||||
let share = Share::new(
|
||||
owner_id,
|
||||
target_user_id,
|
||||
req.resource_type,
|
||||
resource_id,
|
||||
permissions,
|
||||
expires_at,
|
||||
);
|
||||
|
||||
match state.db.create_share(&share).await {
|
||||
Ok(_) => {
|
||||
let response: ShareResponse = match share.try_into() {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "failed to create share response"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
(StatusCode::CREATED, Json(response)).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create share: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "failed to create share"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_shares(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
) -> impl IntoResponse {
|
||||
let user_id = &claims.sub;
|
||||
|
||||
match state.db.list_shares_for_user(user_id).await {
|
||||
Ok(shares) => {
|
||||
let responses: Vec<ShareResponse> = shares
|
||||
.into_iter()
|
||||
.filter_map(|s| ShareResponse::try_from(s).ok())
|
||||
.collect();
|
||||
(StatusCode::OK, Json(responses)).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to list shares: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "failed to list shares"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_share(State(state): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
|
||||
match state.db.get_share(&id).await {
|
||||
Ok(Some(share)) => {
|
||||
let response: ShareResponse = match share.try_into() {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "failed to create share response"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
(StatusCode::OK, Json(response)).into_response()
|
||||
}
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": "share not found"
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to get share: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "failed to get share"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct UpdateShareRequest {
|
||||
pub permissions: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub active: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub expires_days: Option<u64>,
|
||||
}
|
||||
|
||||
pub async fn update_share(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Json(req): Json<UpdateShareRequest>,
|
||||
) -> impl IntoResponse {
|
||||
// First get the share
|
||||
let mut share = match state.db.get_share(&id).await {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": "share not found"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to get share: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "failed to get share"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Verify ownership
|
||||
let owner_id = match ObjectId::parse_str(&claims.sub) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid user ID format"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if share.owner_id != owner_id {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "not authorized to modify this share"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if let Some(permissions) = req.permissions {
|
||||
share.permissions = permissions
|
||||
.into_iter()
|
||||
.filter_map(|p| match p.to_lowercase().as_str() {
|
||||
"read" => Some(Permission::Read),
|
||||
"write" => Some(Permission::Write),
|
||||
"delete" => Some(Permission::Delete),
|
||||
"share" => Some(Permission::Share),
|
||||
"admin" => Some(Permission::Admin),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
if let Some(active) = req.active {
|
||||
share.active = active;
|
||||
}
|
||||
|
||||
if let Some(days) = req.expires_days {
|
||||
share.expires_at = Some(
|
||||
mongodb::bson::DateTime::now().saturating_add_millis(days as i64 * 24 * 60 * 60 * 1000),
|
||||
);
|
||||
}
|
||||
|
||||
match state.db.update_share(&share).await {
|
||||
Ok(_) => {
|
||||
let response: ShareResponse = match share.try_into() {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "failed to create share response"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
(StatusCode::OK, Json(response)).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to update share: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "failed to update share"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_share(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
) -> impl IntoResponse {
|
||||
// First get the share to verify ownership
|
||||
let share = match state.db.get_share(&id).await {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": "share not found"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to get share: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "failed to get share"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Verify ownership
|
||||
let owner_id = match ObjectId::parse_str(&claims.sub) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid user ID format"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if share.owner_id != owner_id {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "not authorized to delete this share"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match state.db.delete_share(&id).await {
|
||||
Ok(_) => (StatusCode::NO_CONTENT, ()).into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to delete share: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "failed to delete share"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ pub struct UpdateProfileRequest {
|
|||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_account(
|
||||
pub async fn get_profile(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
) -> impl IntoResponse {
|
||||
|
|
@ -79,7 +79,7 @@ pub async fn get_account(
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn update_account(
|
||||
pub async fn update_profile(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Json(req): Json<UpdateProfileRequest>,
|
||||
|
|
|
|||
96
backend/src/middleware/permission.rs
Normal file
96
backend/src/middleware/permission.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use crate::config::AppState;
|
||||
use crate::auth::Claims;
|
||||
|
||||
/// Middleware to check if user has permission for a resource
|
||||
///
|
||||
/// This middleware checks JWT claims (attached by auth middleware)
|
||||
/// and verifies the user has the required permission level.
|
||||
///
|
||||
/// # Permission Levels
|
||||
/// - "read": Can view resource
|
||||
/// - "write": Can modify resource
|
||||
/// - "admin": Full control including deletion
|
||||
pub async fn has_permission(
|
||||
State(state): State<AppState>,
|
||||
required_permission: String,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
// Extract user_id from JWT claims (attached by auth middleware)
|
||||
let user_id = match request.extensions().get::<Claims>() {
|
||||
Some(claims) => claims.sub.clone(),
|
||||
None => return Err(StatusCode::UNAUTHORIZED),
|
||||
};
|
||||
|
||||
// Extract resource_id from URL path
|
||||
let resource_id = match extract_resource_id(request.uri().path()) {
|
||||
Some(id) => id,
|
||||
None => return Err(StatusCode::BAD_REQUEST),
|
||||
};
|
||||
|
||||
// Check if user has the required permission (either directly or through shares)
|
||||
let has_perm = match state.db
|
||||
.check_permission(&user_id, &resource_id, &required_permission)
|
||||
.await
|
||||
{
|
||||
Ok(allowed) => allowed,
|
||||
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
};
|
||||
|
||||
if !has_perm {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
/// Extract resource ID from URL path
|
||||
///
|
||||
/// # Examples
|
||||
/// - /api/shares/123 -> Some("123")
|
||||
/// - /api/users/me/profile -> None
|
||||
fn extract_resource_id(path: &str) -> Option<String> {
|
||||
let segments: Vec<&str> = path.split('/').collect();
|
||||
|
||||
// Look for ID segment after a resource type
|
||||
// e.g., /api/shares/:id
|
||||
for (i, segment) in segments.iter().enumerate() {
|
||||
if segment == &"shares" || segment == &"permissions" {
|
||||
if i + 1 < segments.len() {
|
||||
let id = segments[i + 1];
|
||||
if !id.is_empty() {
|
||||
return Some(id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_resource_id() {
|
||||
assert_eq!(
|
||||
extract_resource_id("/api/shares/123"),
|
||||
Some("123".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_resource_id("/api/shares/abc-123"),
|
||||
Some("abc-123".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_resource_id("/api/users/me"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -126,21 +126,16 @@ impl AppointmentRepository {
|
|||
Ok(appointment)
|
||||
}
|
||||
|
||||
/// List a user's appointments, optionally filtered by top-level `status`
|
||||
/// and/or `profileId`.
|
||||
/// List a user's appointments, optionally filtered by top-level `status`.
|
||||
pub async fn find_by_user_filtered(
|
||||
&self,
|
||||
user_id: &str,
|
||||
status: Option<&str>,
|
||||
profile_id: Option<&str>,
|
||||
) -> Result<Vec<Appointment>, Box<dyn std::error::Error>> {
|
||||
let mut filter = doc! { "userId": user_id };
|
||||
if let Some(s) = status {
|
||||
filter.insert("status", s);
|
||||
}
|
||||
if let Some(p) = profile_id {
|
||||
filter.insert("profileId", p);
|
||||
}
|
||||
let mut cursor = self.collection.find(filter, None).await?;
|
||||
let mut appointments = Vec::new();
|
||||
while let Some(appt) = cursor.next().await {
|
||||
|
|
|
|||
|
|
@ -15,9 +15,6 @@ pub struct HealthStatistic {
|
|||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<ObjectId>,
|
||||
pub user_id: String,
|
||||
/// Which profile this stat belongs to (plaintext, filterable).
|
||||
#[serde(rename = "profileId", default)]
|
||||
pub profile_id: String,
|
||||
/// Opaque client-encrypted blob (value, unit, type, notes inside).
|
||||
#[serde(rename = "encryptedData")]
|
||||
pub encrypted_data: EncryptedFieldWire,
|
||||
|
|
@ -30,7 +27,6 @@ pub struct HealthStatistic {
|
|||
pub struct HealthStatResponse {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub profile_id: String,
|
||||
pub encrypted_data: EncryptedFieldWire,
|
||||
pub recorded_at: String,
|
||||
}
|
||||
|
|
@ -40,7 +36,6 @@ impl From<HealthStatistic> for HealthStatResponse {
|
|||
HealthStatResponse {
|
||||
id: s.id.map(|o| o.to_hex()).unwrap_or_default(),
|
||||
user_id: s.user_id,
|
||||
profile_id: s.profile_id,
|
||||
encrypted_data: s.encrypted_data,
|
||||
recorded_at: s.recorded_at,
|
||||
}
|
||||
|
|
@ -75,21 +70,6 @@ impl HealthStatisticsRepository {
|
|||
cursor.try_collect().await
|
||||
}
|
||||
|
||||
/// All stats for a user, optionally filtered by profile_id at the Mongo
|
||||
/// level (real top-level document field).
|
||||
pub async fn find_by_user_filtered(
|
||||
&self,
|
||||
user_id: &str,
|
||||
profile_id: Option<&str>,
|
||||
) -> Result<Vec<HealthStatistic>, MongoError> {
|
||||
let mut filter = doc! { "user_id": user_id };
|
||||
if let Some(pid) = profile_id {
|
||||
filter.insert("profileId", pid);
|
||||
}
|
||||
let cursor = self.collection.find(filter, None).await?;
|
||||
cursor.try_collect().await
|
||||
}
|
||||
|
||||
pub async fn find_by_id(&self, id: &ObjectId) -> Result<Option<HealthStatistic>, MongoError> {
|
||||
let filter = doc! { "_id": id };
|
||||
self.collection.find_one(filter, None).await
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ pub mod health_stats;
|
|||
pub mod interactions;
|
||||
pub mod lab_result;
|
||||
pub mod medication;
|
||||
pub mod permission;
|
||||
pub mod profile;
|
||||
pub mod profile_share;
|
||||
pub mod refresh_token;
|
||||
pub mod session;
|
||||
pub mod share;
|
||||
pub mod user;
|
||||
|
|
|
|||
44
backend/src/models/permission.rs
Normal file
44
backend/src/models/permission.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Permission {
|
||||
Read,
|
||||
Write,
|
||||
Delete,
|
||||
Share,
|
||||
Admin,
|
||||
}
|
||||
|
||||
impl fmt::Display for Permission {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Read => write!(f, "read"),
|
||||
Self::Write => write!(f, "write"),
|
||||
Self::Delete => write!(f, "delete"),
|
||||
Self::Share => write!(f, "share"),
|
||||
Self::Admin => write!(f, "admin"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Permission {
|
||||
pub fn can_read(&self) -> bool {
|
||||
matches!(self, Self::Read | Self::Admin)
|
||||
}
|
||||
|
||||
pub fn can_write(&self) -> bool {
|
||||
matches!(self, Self::Write | Self::Admin)
|
||||
}
|
||||
|
||||
pub fn can_delete(&self) -> bool {
|
||||
matches!(self, Self::Delete | Self::Admin)
|
||||
}
|
||||
|
||||
pub fn can_share(&self) -> bool {
|
||||
matches!(self, Self::Share | Self::Admin)
|
||||
}
|
||||
}
|
||||
|
||||
pub type Permissions = Vec<Permission>;
|
||||
|
|
@ -1,76 +1,35 @@
|
|||
use futures::stream::StreamExt;
|
||||
use mongodb::{
|
||||
bson::{doc, oid::ObjectId, DateTime},
|
||||
Collection,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A profile (a "subject of care" — a person or pet whose health data is
|
||||
/// tracked). One account owns zero or more profiles. All health data
|
||||
/// (medications, appointments, health stats) is scoped to a profile and
|
||||
/// encrypted under that profile's DEK.
|
||||
///
|
||||
/// Zero-knowledge layers (both opaque to the server):
|
||||
/// - `name` / `wrapped_profile_dek` are AES-256-GCM ciphertext blobs the
|
||||
/// server stores verbatim and cannot read.
|
||||
/// - `name` is the profile's display name encrypted under the profile DEK.
|
||||
/// - `wrapped_profile_dek` is the profile DEK encrypted under the owner's
|
||||
/// account DEK. The client unwraps it at login/unlock (after unwrapping
|
||||
/// the account DEK) to get the profile DEK.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Profile {
|
||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<ObjectId>,
|
||||
#[serde(rename = "profileId")]
|
||||
pub profile_id: String,
|
||||
/// Owner account id (Mongo ObjectId hex). Replaces the 1:1 `user_id`
|
||||
/// semantics from the pre-A2 single-profile model.
|
||||
#[serde(rename = "ownerAccountId")]
|
||||
pub owner_account_id: String,
|
||||
/// Kept populated = owner_account_id for backward compat with existing
|
||||
/// medication/appointment filters that key on `userId`. Same value; do
|
||||
/// not rely on it for ownership — use `owner_account_id`.
|
||||
#[serde(rename = "userId")]
|
||||
pub user_id: String,
|
||||
#[serde(rename = "familyId")]
|
||||
pub family_id: Option<String>,
|
||||
/// Display name — opaque, encrypted under the profile DEK.
|
||||
#[serde(rename = "name")]
|
||||
pub name: String,
|
||||
#[serde(rename = "nameIv")]
|
||||
pub name_iv: String,
|
||||
#[serde(rename = "nameAuthTag")]
|
||||
pub name_auth_tag: String,
|
||||
/// "human" | "pet" — what kind of subject this profile tracks.
|
||||
#[serde(rename = "kind", default = "default_kind")]
|
||||
pub kind: String,
|
||||
/// Freeform relationship to the owner: "self", "child", "spouse",
|
||||
/// "parent", "pet", ...
|
||||
#[serde(rename = "relationship", default)]
|
||||
pub relationship: String,
|
||||
#[serde(rename = "role", default = "default_role")]
|
||||
#[serde(rename = "role")]
|
||||
pub role: String,
|
||||
#[serde(rename = "permissions", default)]
|
||||
#[serde(rename = "permissions")]
|
||||
pub permissions: Vec<String>,
|
||||
/// Profile DEK wrapped under the owner's account DEK (opaque ciphertext).
|
||||
#[serde(rename = "wrappedProfileDek")]
|
||||
pub wrapped_profile_dek: String,
|
||||
#[serde(rename = "wrappedProfileDekIv")]
|
||||
pub wrapped_profile_dek_iv: String,
|
||||
#[serde(rename = "createdAt")]
|
||||
pub created_at: DateTime,
|
||||
#[serde(rename = "updatedAt")]
|
||||
pub updated_at: DateTime,
|
||||
}
|
||||
|
||||
fn default_kind() -> String {
|
||||
"human".to_string()
|
||||
}
|
||||
|
||||
fn default_role() -> String {
|
||||
"patient".to_string()
|
||||
}
|
||||
|
||||
pub struct ProfileRepository {
|
||||
collection: Collection<Profile>,
|
||||
}
|
||||
|
|
@ -85,7 +44,6 @@ impl ProfileRepository {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up a profile by its application-level profile id.
|
||||
pub async fn find_by_profile_id(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
|
|
@ -95,102 +53,32 @@ impl ProfileRepository {
|
|||
.await
|
||||
}
|
||||
|
||||
/// Look up a profile by id AND owner — used for ownership-scoped access.
|
||||
/// Returns None if the profile doesn't exist or doesn't belong to `owner`.
|
||||
pub async fn find_by_profile_id_owned(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
owner: &str,
|
||||
) -> mongodb::error::Result<Option<Profile>> {
|
||||
/// Look up a profile by its owning user id.
|
||||
pub async fn find_by_user_id(&self, user_id: &str) -> mongodb::error::Result<Option<Profile>> {
|
||||
self.collection
|
||||
.find_one(
|
||||
doc! { "profileId": profile_id, "ownerAccountId": owner },
|
||||
None,
|
||||
)
|
||||
.find_one(doc! { "userId": user_id }, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// All profiles owned by an account.
|
||||
pub async fn find_all_by_owner(&self, owner: &str) -> mongodb::error::Result<Vec<Profile>> {
|
||||
let mut cursor = self
|
||||
.collection
|
||||
.find(doc! { "ownerAccountId": owner }, None)
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(p) = cursor.next().await {
|
||||
out.push(p?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Replace a profile's mutable fields (display name blob, kind,
|
||||
/// relationship), keyed by (profile_id, owner). Returns the updated doc
|
||||
/// or None if the profile doesn't exist / isn't owned by `owner`.
|
||||
pub async fn update_profile(
|
||||
/// Update the profile's display name (opaque client-encrypted blob).
|
||||
pub async fn update_encrypted_name(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
owner: &str,
|
||||
user_id: &str,
|
||||
name_data: &str,
|
||||
name_iv: &str,
|
||||
name_auth_tag: &str,
|
||||
kind: &str,
|
||||
relationship: &str,
|
||||
) -> mongodb::error::Result<Option<Profile>> {
|
||||
self.collection
|
||||
.find_one_and_update(
|
||||
doc! { "profileId": profile_id, "ownerAccountId": owner },
|
||||
doc! { "userId": user_id },
|
||||
doc! { "$set": {
|
||||
"name": name_data,
|
||||
"nameIv": name_iv,
|
||||
"nameAuthTag": name_auth_tag,
|
||||
"kind": kind,
|
||||
"relationship": relationship,
|
||||
"updatedAt": DateTime::now()
|
||||
}},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Rotate the profile's wrapped DEK (owner share) — Phase C hard revoke.
|
||||
/// Stores the new account-wrapped profile DEK verbatim, keyed by
|
||||
/// (profile_id, owner). Returns the updated profile or None if it doesn't
|
||||
/// exist / isn't owned by `owner`. The server stores the opaque blob
|
||||
/// verbatim and cannot decrypt it.
|
||||
pub async fn update_wrapped_dek(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
owner: &str,
|
||||
new_wrapped_profile_dek: &str,
|
||||
new_wrapped_profile_dek_iv: &str,
|
||||
) -> mongodb::error::Result<Option<Profile>> {
|
||||
self.collection
|
||||
.find_one_and_update(
|
||||
doc! { "profileId": profile_id, "ownerAccountId": owner },
|
||||
doc! { "$set": {
|
||||
"wrappedProfileDek": new_wrapped_profile_dek,
|
||||
"wrappedProfileDekIv": new_wrapped_profile_dek_iv,
|
||||
"updatedAt": DateTime::now()
|
||||
}},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete a profile keyed by (profile_id, owner). Returns true if a doc
|
||||
/// was deleted, false if it didn't exist or wasn't owned by `owner`.
|
||||
pub async fn delete_profile(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
owner: &str,
|
||||
) -> mongodb::error::Result<bool> {
|
||||
let res = self
|
||||
.collection
|
||||
.delete_one(
|
||||
doc! { "profileId": profile_id, "ownerAccountId": owner },
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(res.deleted_count > 0)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,222 +0,0 @@
|
|||
use futures::stream::StreamExt;
|
||||
use mongodb::{
|
||||
bson::{doc, oid::ObjectId, DateTime},
|
||||
Collection,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A profile shared from one account (owner) to another (recipient).
|
||||
///
|
||||
/// Zero-knowledge envelope (Phase B, see `docs/adr/multi-person-sharing.md`):
|
||||
/// the owner wraps the profile DEK to the recipient's X25519 identity public
|
||||
/// key via ECDH, using a fresh ephemeral keypair per share. The server stores
|
||||
/// only opaque ciphertext + public keys and cannot read the profile DEK.
|
||||
///
|
||||
/// `permissions` reserves `read` (Phase B), `write`, and `admin` for later
|
||||
/// phases. Phase B only grants read access.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfileShare {
|
||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<ObjectId>,
|
||||
#[serde(rename = "profileId")]
|
||||
pub profile_id: String,
|
||||
/// Owner's account id (the profile's owner). Denormalized from the
|
||||
/// profile for query efficiency without a join.
|
||||
#[serde(rename = "ownerUserId")]
|
||||
pub owner_user_id: String,
|
||||
/// Recipient's account id.
|
||||
#[serde(rename = "recipientUserId")]
|
||||
pub recipient_user_id: String,
|
||||
/// Ephemeral X25519 public key (base64 raw) generated for this share. The
|
||||
/// recipient combines it with their identity private key to ECDH-derive
|
||||
/// the wrapping key. Plaintext — public keys are not secret.
|
||||
#[serde(rename = "ephemeralPublicKey")]
|
||||
pub ephemeral_public_key: String,
|
||||
/// Profile DEK wrapped (AES-256-GCM) under the ECDH-derived key. Opaque.
|
||||
#[serde(rename = "wrappedProfileDek")]
|
||||
pub wrapped_profile_dek: String,
|
||||
#[serde(rename = "wrappedProfileDekIv")]
|
||||
pub wrapped_profile_dek_iv: String,
|
||||
/// Reserved for later phases. Phase B writes `["read"]`.
|
||||
#[serde(rename = "permissions", default = "default_read")]
|
||||
pub permissions: Vec<String>,
|
||||
/// Optional expiry; if set and past, `find_active` treats the share as gone.
|
||||
#[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<DateTime>,
|
||||
#[serde(rename = "createdAt")]
|
||||
pub created_at: DateTime,
|
||||
/// Supports a future "disable without delete" path. Phase B soft-revoke
|
||||
/// hard-deletes the doc, but the field is kept for forward-compat.
|
||||
#[serde(rename = "active", default = "default_true")]
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
fn default_read() -> Vec<String> {
|
||||
vec!["read".to_string()]
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub struct ProfileShareRepository {
|
||||
collection: Collection<ProfileShare>,
|
||||
}
|
||||
|
||||
impl ProfileShareRepository {
|
||||
pub fn new(collection: Collection<ProfileShare>) -> Self {
|
||||
Self { collection }
|
||||
}
|
||||
|
||||
pub async fn create(&self, share: &ProfileShare) -> mongodb::error::Result<()> {
|
||||
self.collection.insert_one(share, None).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// All shares where the given account is the recipient (for the
|
||||
/// `/profiles/shared-with-me` endpoint).
|
||||
pub async fn find_for_recipient(
|
||||
&self,
|
||||
recipient_user_id: &str,
|
||||
) -> mongodb::error::Result<Vec<ProfileShare>> {
|
||||
let mut cursor = self
|
||||
.collection
|
||||
.find(doc! { "recipientUserId": recipient_user_id }, None)
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(s) = cursor.next().await {
|
||||
out.push(s?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// All shares for a given profile (owner listing who they've shared with).
|
||||
pub async fn find_for_profile(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
) -> mongodb::error::Result<Vec<ProfileShare>> {
|
||||
let mut cursor = self
|
||||
.collection
|
||||
.find(doc! { "profileId": profile_id }, None)
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(s) = cursor.next().await {
|
||||
out.push(s?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A specific (profile, recipient) share regardless of active/expiry state.
|
||||
pub async fn find(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
recipient_user_id: &str,
|
||||
) -> mongodb::error::Result<Option<ProfileShare>> {
|
||||
self.collection
|
||||
.find_one(
|
||||
doc! { "profileId": profile_id, "recipientUserId": recipient_user_id },
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// A specific (profile, recipient) share, only if currently usable:
|
||||
/// `active == true` and not past `expires_at`. Used by the share-gate.
|
||||
pub async fn find_active(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
recipient_user_id: &str,
|
||||
) -> mongodb::error::Result<Option<ProfileShare>> {
|
||||
let now = DateTime::now();
|
||||
// active==true AND (expiresAt missing OR expiresAt > now)
|
||||
let filter = doc! {
|
||||
"profileId": profile_id,
|
||||
"recipientUserId": recipient_user_id,
|
||||
"active": true,
|
||||
"$or": [
|
||||
{ "expiresAt": { "$exists": false } },
|
||||
{ "expiresAt": null },
|
||||
{ "expiresAt": { "$gt": now } },
|
||||
],
|
||||
};
|
||||
self.collection.find_one(filter, None).await
|
||||
}
|
||||
|
||||
/// All currently-active shares for a profile (Phase C rekey needs the full
|
||||
/// recipient list to validate submitted envelopes and hard-delete omitted
|
||||
/// recipients). "Active" = `active==true` and not past `expires_at`.
|
||||
pub async fn find_active_for_profile(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
) -> mongodb::error::Result<Vec<ProfileShare>> {
|
||||
let now = DateTime::now();
|
||||
let filter = doc! {
|
||||
"profileId": profile_id,
|
||||
"active": true,
|
||||
"$or": [
|
||||
{ "expiresAt": { "$exists": false } },
|
||||
{ "expiresAt": null },
|
||||
{ "expiresAt": { "$gt": now } },
|
||||
],
|
||||
};
|
||||
let mut cursor = self.collection.find(filter, None).await?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(s) = cursor.next().await {
|
||||
out.push(s?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Hard-delete every share for the profile whose recipient is NOT in
|
||||
/// `keep_recipient_ids` — the hard-revoke action in Phase C rekey. Recipients
|
||||
/// omitted from the rekey call lose access at the key level (their cached
|
||||
/// old DEK won't match the rotated one) AND the server stops serving them.
|
||||
/// Returns the number deleted.
|
||||
pub async fn delete_for_profile_excluding(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
keep_recipient_ids: &[String],
|
||||
) -> mongodb::error::Result<u64> {
|
||||
let mut filter = doc! { "profileId": profile_id };
|
||||
if !keep_recipient_ids.is_empty() {
|
||||
filter.insert("recipientUserId", doc! { "$nin": keep_recipient_ids });
|
||||
}
|
||||
let res = self.collection.delete_many(filter, None).await?;
|
||||
Ok(res.deleted_count)
|
||||
}
|
||||
|
||||
/// Hard-delete (soft-revoke) the (profile, recipient) share. Returns true
|
||||
/// if a doc was deleted.
|
||||
pub async fn delete(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
recipient_user_id: &str,
|
||||
) -> mongodb::error::Result<bool> {
|
||||
let res = self
|
||||
.collection
|
||||
.delete_one(
|
||||
doc! { "profileId": profile_id, "recipientUserId": recipient_user_id },
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(res.deleted_count > 0)
|
||||
}
|
||||
|
||||
/// Upsert: replace any existing (profile, recipient) share with the given
|
||||
/// one. Used when re-sharing (e.g. rotating the ephemeral key). Deletes
|
||||
/// existing rows for the pair first, then inserts.
|
||||
pub async fn upsert(&self, share: &ProfileShare) -> mongodb::error::Result<()> {
|
||||
let _ = self
|
||||
.collection
|
||||
.delete_one(
|
||||
doc! {
|
||||
"profileId": &share.profile_id,
|
||||
"recipientUserId": &share.recipient_user_id,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
self.collection.insert_one(share, None).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
123
backend/src/models/share.rs
Normal file
123
backend/src/models/share.rs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
use mongodb::bson::DateTime;
|
||||
use mongodb::bson::{doc, oid::ObjectId};
|
||||
use mongodb::Collection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::permission::Permission;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Share {
|
||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<ObjectId>,
|
||||
pub owner_id: ObjectId,
|
||||
pub target_user_id: ObjectId,
|
||||
pub resource_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resource_id: Option<ObjectId>,
|
||||
pub permissions: Vec<Permission>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<DateTime>,
|
||||
pub created_at: DateTime,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
impl Share {
|
||||
pub fn new(
|
||||
owner_id: ObjectId,
|
||||
target_user_id: ObjectId,
|
||||
resource_type: String,
|
||||
resource_id: Option<ObjectId>,
|
||||
permissions: Vec<Permission>,
|
||||
expires_at: Option<DateTime>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
owner_id,
|
||||
target_user_id,
|
||||
resource_type,
|
||||
resource_id,
|
||||
permissions,
|
||||
expires_at,
|
||||
created_at: DateTime::now(),
|
||||
active: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
if let Some(expires) = self.expires_at {
|
||||
DateTime::now() > expires
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_permission(&self, permission: &Permission) -> bool {
|
||||
self.permissions.contains(permission) || self.permissions.contains(&Permission::Admin)
|
||||
}
|
||||
|
||||
pub fn revoke(&mut self) {
|
||||
self.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ShareRepository {
|
||||
collection: Collection<Share>,
|
||||
}
|
||||
|
||||
impl ShareRepository {
|
||||
pub fn new(collection: Collection<Share>) -> Self {
|
||||
Self { collection }
|
||||
}
|
||||
|
||||
pub async fn create(&self, share: &Share) -> mongodb::error::Result<Option<ObjectId>> {
|
||||
let result = self.collection.insert_one(share, None).await?;
|
||||
Ok(Some(result.inserted_id.as_object_id().unwrap()))
|
||||
}
|
||||
|
||||
pub async fn find_by_id(&self, id: &ObjectId) -> mongodb::error::Result<Option<Share>> {
|
||||
self.collection.find_one(doc! { "_id": id }, None).await
|
||||
}
|
||||
|
||||
pub async fn find_by_owner(&self, owner_id: &ObjectId) -> mongodb::error::Result<Vec<Share>> {
|
||||
use futures::stream::TryStreamExt;
|
||||
|
||||
self.collection
|
||||
.find(doc! { "owner_id": owner_id }, None)
|
||||
.await?
|
||||
.try_collect()
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_target(
|
||||
&self,
|
||||
target_user_id: &ObjectId,
|
||||
) -> mongodb::error::Result<Vec<Share>> {
|
||||
use futures::stream::TryStreamExt;
|
||||
|
||||
self.collection
|
||||
.find(
|
||||
doc! { "target_user_id": target_user_id, "active": true },
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
.try_collect()
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update(&self, share: &Share) -> mongodb::error::Result<()> {
|
||||
if let Some(id) = &share.id {
|
||||
self.collection
|
||||
.replace_one(doc! { "_id": id }, share, None)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(&self, share_id: &ObjectId) -> mongodb::error::Result<()> {
|
||||
self.collection
|
||||
.delete_one(doc! { "_id": share_id }, None)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -37,21 +37,6 @@ pub struct User {
|
|||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub recovery_wrapped_dek_iv: Option<String>,
|
||||
|
||||
/// Account X25519 identity public key (base64 raw). Plaintext — public keys
|
||||
/// are not secret. Other accounts wrap profile DEKs to this key (Phase B,
|
||||
/// see docs/adr/multi-person-sharing.md). Generated client-side at
|
||||
/// registration.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub identity_public_key: Option<String>,
|
||||
|
||||
/// Account X25519 identity private key, wrapped (AES-256-GCM encrypted)
|
||||
/// under the account DEK. The server stores this verbatim and cannot
|
||||
/// decrypt it. The client unwraps it at login after unwrapping the DEK.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub identity_private_key_wrapped: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub identity_private_key_wrapped_iv: Option<String>,
|
||||
|
||||
/// Token version for invalidating all tokens on password change
|
||||
pub token_version: i32,
|
||||
|
||||
|
|
@ -108,9 +93,6 @@ impl User {
|
|||
wrapped_dek_iv: None,
|
||||
recovery_wrapped_dek: None,
|
||||
recovery_wrapped_dek_iv: None,
|
||||
identity_public_key: None,
|
||||
identity_private_key_wrapped: None,
|
||||
identity_private_key_wrapped_iv: None,
|
||||
token_version: 0,
|
||||
created_at: now,
|
||||
last_active: now,
|
||||
|
|
|
|||
|
|
@ -287,80 +287,6 @@ async fn password_change_invalidates_existing_tokens() {
|
|||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn identity_keypair_round_trips_through_register_and_login() {
|
||||
// Phase A1 (#3): the X25519 identity keypair fields (public plaintext,
|
||||
// private wrapped under the account DEK) are stored verbatim at register
|
||||
// and echoed back on login. The server never inspects or derives them.
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let email = unique_email();
|
||||
|
||||
// Opaque blobs — the server treats these as base64 strings, nothing more.
|
||||
let public_key = "base64-x25519-public-key-for-test";
|
||||
let priv_wrapped = "base64-wrapped-priv-ciphertext";
|
||||
let priv_wrapped_iv = "base64-12-byte-iv";
|
||||
|
||||
let (status, body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
Some(json!({
|
||||
"email": email,
|
||||
"username": "tester",
|
||||
"password": "supersecret",
|
||||
"identity_public_key": public_key,
|
||||
"identity_private_key_wrapped": priv_wrapped,
|
||||
"identity_private_key_wrapped_iv": priv_wrapped_iv,
|
||||
})),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201, "register should return 201, body: {body}");
|
||||
assert_eq!(body["identity_public_key"], public_key);
|
||||
assert_eq!(body["identity_private_key_wrapped"], priv_wrapped);
|
||||
assert_eq!(body["identity_private_key_wrapped_iv"], priv_wrapped_iv);
|
||||
|
||||
// Login must echo the same stored values.
|
||||
let (status, body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
"/api/auth/login",
|
||||
Some(json!({ "email": email, "password": "supersecret" })),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "login should return 200, body: {body}");
|
||||
assert_eq!(body["identity_public_key"], public_key);
|
||||
assert_eq!(body["identity_private_key_wrapped"], priv_wrapped);
|
||||
assert_eq!(body["identity_private_key_wrapped_iv"], priv_wrapped_iv);
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_without_identity_keypair_stays_optional() {
|
||||
// Backward compat: existing clients that don't send identity fields must
|
||||
// still register successfully, and the fields are absent from the response.
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let email = unique_email();
|
||||
|
||||
let (status, body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
Some(json!({ "email": email, "username": "tester", "password": "supersecret" })),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201, "register should return 201, body: {body}");
|
||||
assert!(
|
||||
body.get("identity_public_key").is_none() || body["identity_public_key"].is_null(),
|
||||
"identity_public_key should be absent when not provided: {body}"
|
||||
);
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
fn unique_email() -> String {
|
||||
|
|
|
|||
|
|
@ -1,258 +0,0 @@
|
|||
//! Write/delete path ownership tests (#12).
|
||||
//!
|
||||
//! Verifies the ownership checks added to fix the IDOR: user A creates a
|
||||
//! medication/appointment; user B cannot update, delete, log-dose-against, or
|
||||
//! read adherence for it. All cross-user attempts must 404 (we use 404, not
|
||||
//! 403, to avoid leaking the existence of other users' records).
|
||||
//!
|
||||
//! Requires a live MongoDB; skips gracefully otherwise.
|
||||
|
||||
mod common;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
macro_rules! require_app {
|
||||
($app:expr) => {
|
||||
match $app {
|
||||
Some(x) => x,
|
||||
None => {
|
||||
eprintln!("[integration] skipped (MongoDB unavailable)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn unique_email() -> String {
|
||||
format!("test_{}@example.com", uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// Register a user and return the access token.
|
||||
async fn register(app: &axum::Router, email: &str) -> String {
|
||||
let (status, body) = common::send_json(
|
||||
app,
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
Some(json!({ "email": email, "username": "tester", "password": "supersecret" })),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201, "register failed, body: {body}");
|
||||
body["token"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Create a medication as `token` and return its medication_id.
|
||||
async fn create_medication(app: &axum::Router, token: &str) -> String {
|
||||
let (status, body) = common::send_json(
|
||||
app,
|
||||
"POST",
|
||||
"/api/medications",
|
||||
Some(json!({
|
||||
"profile_id": "default",
|
||||
"encrypted_data": { "data": "b3duZXItYmxvYg==", "iv": "aXZ2aXZ2aXZ2aXZ2" },
|
||||
})),
|
||||
Some(token),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status == 200 || status == 201,
|
||||
"create_medication failed: {status} {body}"
|
||||
);
|
||||
body["medication_id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Create an appointment as `token` and return its appointment_id.
|
||||
async fn create_appointment(app: &axum::Router, token: &str) -> String {
|
||||
let (status, body) = common::send_json(
|
||||
app,
|
||||
"POST",
|
||||
"/api/appointments",
|
||||
Some(json!({
|
||||
"profile_id": "default",
|
||||
"encrypted_data": { "data": "YXBwdC1ibG9i", "iv": "aXZ2aXZ2aXZ2aXZ2" },
|
||||
"status": "upcoming",
|
||||
})),
|
||||
Some(token),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status == 200 || status == 201,
|
||||
"create_appointment failed: {status} {body}"
|
||||
);
|
||||
body["appointment_id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn other_user_cannot_update_or_delete_medication() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let token_a = register(&app, &unique_email()).await;
|
||||
let token_b = register(&app, &unique_email()).await;
|
||||
let med_id = create_medication(&app, &token_a).await;
|
||||
|
||||
// B cannot UPDATE A's medication.
|
||||
let (status, _body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/medications/{med_id}"),
|
||||
Some(json!({
|
||||
"encrypted_data": { "data": "aGFja2Vk", "iv": "aXZ2aXZ2aXZ2aXZ2" },
|
||||
})),
|
||||
Some(&token_b),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404, "B must not update A's medication");
|
||||
|
||||
// B cannot DELETE A's medication.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/medications/{med_id}/delete"),
|
||||
None,
|
||||
Some(&token_b),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404, "B must not delete A's medication");
|
||||
|
||||
// A still sees it intact.
|
||||
let (status, _body) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/medications/{med_id}"),
|
||||
None,
|
||||
Some(&token_a),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "A's medication must be untouched");
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn other_user_cannot_log_dose_or_read_adherence_for_others_medication() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let token_a = register(&app, &unique_email()).await;
|
||||
let token_b = register(&app, &unique_email()).await;
|
||||
let med_id = create_medication(&app, &token_a).await;
|
||||
|
||||
// B cannot LOG A DOSE against A's medication (would skew A's adherence).
|
||||
let (status, _body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/medications/{med_id}/log"),
|
||||
Some(json!({ "taken": true })),
|
||||
Some(&token_b),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404, "B must not log a dose against A's medication");
|
||||
|
||||
// B cannot READ A's adherence (leaks dose history).
|
||||
let (status, _body) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/medications/{med_id}/adherence"),
|
||||
None,
|
||||
Some(&token_b),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404, "B must not read A's adherence");
|
||||
|
||||
// A can both (sanity).
|
||||
let (status, _body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/medications/{med_id}/log"),
|
||||
Some(json!({ "taken": true })),
|
||||
Some(&token_a),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201, "A should be able to log a dose: {_body}");
|
||||
let (status, _body) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/medications/{med_id}/adherence"),
|
||||
None,
|
||||
Some(&token_a),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "A should be able to read adherence");
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn other_user_cannot_update_or_delete_appointment() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let token_a = register(&app, &unique_email()).await;
|
||||
let token_b = register(&app, &unique_email()).await;
|
||||
let appt_id = create_appointment(&app, &token_a).await;
|
||||
|
||||
// B cannot UPDATE A's appointment.
|
||||
let (status, _body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/appointments/{appt_id}"),
|
||||
Some(json!({
|
||||
"encrypted_data": { "data": "aGFja2Vk", "iv": "aXZ2aXZ2aXZ2aXZ2" },
|
||||
"status": "cancelled",
|
||||
})),
|
||||
Some(&token_b),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404, "B must not update A's appointment");
|
||||
|
||||
// B cannot DELETE A's appointment.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/appointments/{appt_id}/delete"),
|
||||
None,
|
||||
Some(&token_b),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404, "B must not delete A's appointment");
|
||||
|
||||
// A still sees it.
|
||||
let (status, _body): (u16, Value) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/appointments/{appt_id}"),
|
||||
None,
|
||||
Some(&token_a),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "A's appointment must be untouched");
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owner_can_update_and_delete_own_medication() {
|
||||
// Sanity: the new checks must not block the legitimate owner.
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let token = register(&app, &unique_email()).await;
|
||||
let med_id = create_medication(&app, &token).await;
|
||||
|
||||
let (status, _body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/medications/{med_id}"),
|
||||
Some(json!({
|
||||
"encrypted_data": { "data": "dXBkYXRlZA==", "iv": "aXZ2aXZ2aXZ2aXZ2" },
|
||||
})),
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "owner should update: {_body}");
|
||||
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/medications/{med_id}/delete"),
|
||||
None,
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 204, "owner should delete");
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
|
@ -1,255 +0,0 @@
|
|||
//! Multi-profile integration tests (Phase A2).
|
||||
//!
|
||||
//! Exercises the /api/profiles CRUD endpoints: list, create, get, update,
|
||||
//! delete — all ownership-scoped. A second registered user must not be able
|
||||
//! to read or modify the first user's profiles.
|
||||
//!
|
||||
//! Requires a live MongoDB; skips gracefully otherwise (see common/mod.rs).
|
||||
|
||||
mod common;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
macro_rules! require_app {
|
||||
($app:expr) => {
|
||||
match $app {
|
||||
Some(x) => x,
|
||||
None => {
|
||||
eprintln!("[integration] skipped (MongoDB unavailable)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Register a user (sending no default-profile fields → no profile auto-created)
|
||||
/// and return the access token.
|
||||
async fn register(app: &axum::Router, email: &str, password: &str) -> String {
|
||||
let (status, body) = common::send_json(
|
||||
app,
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
Some(json!({ "email": email, "username": "tester", "password": password })),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201, "register precondition failed, body: {body}");
|
||||
body["token"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
fn unique_email() -> String {
|
||||
format!("test_{}@example.com", uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// Body for creating a profile with a fake wrapped DEK (the server stores it
|
||||
/// verbatim; it never inspects the contents).
|
||||
fn create_body(kind: &str, relationship: &str) -> Value {
|
||||
json!({
|
||||
"kind": kind,
|
||||
"relationship": relationship,
|
||||
"name_data": "base64-encrypted-name",
|
||||
"name_iv": "base64-iv",
|
||||
"wrapped_profile_dek": "base64-wrapped-profile-dek",
|
||||
"wrapped_profile_dek_iv": "base64-dek-iv",
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_starts_empty_without_default_profile() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let token = register(&app, &unique_email(), "supersecret").await;
|
||||
|
||||
let (status, body) = common::send_json(&app, "GET", "/api/profiles", None, Some(&token)).await;
|
||||
assert_eq!(status, 200, "list should return 200, body: {body}");
|
||||
assert_eq!(
|
||||
body.as_array().unwrap().len(),
|
||||
0,
|
||||
"no default profile expected"
|
||||
);
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_then_list_then_get_update_delete() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let token = register(&app, &unique_email(), "supersecret").await;
|
||||
|
||||
// Create a profile.
|
||||
let (status, body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
"/api/profiles",
|
||||
Some(create_body("human", "self")),
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201, "create should return 201, body: {body}");
|
||||
let profile_id = body["profile_id"].as_str().unwrap().to_string();
|
||||
assert_eq!(body["kind"], "human");
|
||||
assert_eq!(body["relationship"], "self");
|
||||
assert_eq!(body["wrapped_profile_dek"], "base64-wrapped-profile-dek");
|
||||
|
||||
// List now has one.
|
||||
let (_, list_body) = common::send_json(&app, "GET", "/api/profiles", None, Some(&token)).await;
|
||||
assert_eq!(list_body.as_array().unwrap().len(), 1);
|
||||
|
||||
// Get by id.
|
||||
let (status, get_body) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{profile_id}"),
|
||||
None,
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200);
|
||||
assert_eq!(get_body["profile_id"], profile_id);
|
||||
|
||||
// Update the display-name blob + relationship.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"PUT",
|
||||
&format!("/api/profiles/{profile_id}"),
|
||||
Some(json!({
|
||||
"name_data": "new-name-blob",
|
||||
"name_iv": "new-iv",
|
||||
"kind": "pet",
|
||||
"relationship": "dog",
|
||||
})),
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "update should return 200");
|
||||
|
||||
// Delete.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"DELETE",
|
||||
&format!("/api/profiles/{profile_id}"),
|
||||
None,
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 204, "delete should return 204");
|
||||
|
||||
// Get now 404s.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{profile_id}"),
|
||||
None,
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404);
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn other_user_cannot_access_profile() {
|
||||
// Ownership isolation: user B must not GET/UPDATE/DELETE user A's profile.
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let token_a = register(&app, &unique_email(), "supersecret").await;
|
||||
let token_b = register(&app, &unique_email(), "supersecret").await;
|
||||
|
||||
// A creates a profile.
|
||||
let (_, body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
"/api/profiles",
|
||||
Some(create_body("human", "child")),
|
||||
Some(&token_a),
|
||||
)
|
||||
.await;
|
||||
let profile_id = body["profile_id"].as_str().unwrap().to_string();
|
||||
|
||||
// B cannot GET it (404, because ownership filter excludes it).
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{profile_id}"),
|
||||
None,
|
||||
Some(&token_b),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404, "B must not read A's profile");
|
||||
|
||||
// B cannot UPDATE it.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"PUT",
|
||||
&format!("/api/profiles/{profile_id}"),
|
||||
Some(json!({
|
||||
"name_data": "x", "name_iv": "y", "kind": "human", "relationship": "self"
|
||||
})),
|
||||
Some(&token_b),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404, "B must not update A's profile");
|
||||
|
||||
// B cannot DELETE it.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"DELETE",
|
||||
&format!("/api/profiles/{profile_id}"),
|
||||
None,
|
||||
Some(&token_b),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404, "B must not delete A's profile");
|
||||
|
||||
// A still sees it intact.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{profile_id}"),
|
||||
None,
|
||||
Some(&token_a),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "A's profile must be untouched");
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_with_default_profile_creates_self_profile() {
|
||||
// When register carries default_wrapped_profile_dek, the self profile is
|
||||
// auto-created and shows up in GET /api/profiles.
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let email = unique_email();
|
||||
|
||||
let (_, body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
Some(json!({
|
||||
"email": email,
|
||||
"username": "tester",
|
||||
"password": "supersecret",
|
||||
"default_profile_name_data": "name-blob",
|
||||
"default_profile_name_iv": "name-iv",
|
||||
"default_wrapped_profile_dek": "dek-blob",
|
||||
"default_wrapped_profile_dek_iv": "dek-iv",
|
||||
})),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let token = body["token"].as_str().unwrap().to_string();
|
||||
|
||||
let (_, list_body) = common::send_json(&app, "GET", "/api/profiles", None, Some(&token)).await;
|
||||
let arr = list_body.as_array().unwrap();
|
||||
assert_eq!(arr.len(), 1, "default self profile should exist");
|
||||
assert_eq!(arr[0]["relationship"], "self");
|
||||
assert_eq!(arr[0]["kind"], "human");
|
||||
assert_eq!(arr[0]["wrapped_profile_dek"], "dek-blob");
|
||||
// Deterministic id contract: profile_<owner>.
|
||||
assert!(arr[0]["profile_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("profile_"));
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
|
@ -1,382 +0,0 @@
|
|||
//! Hard-revoke / re-key integration tests (Phase C).
|
||||
//!
|
||||
//! Verifies POST /api/profiles/:id/rekey: rotating the profile's wrapped DEK,
|
||||
//! upserting submitted recipient envelopes (fresh ECDH wraps), and hard-
|
||||
//! deleting any current recipient NOT in the submitted list. Wire-level only
|
||||
//! (opaque blobs — the server never inspects them, so the tests don't need
|
||||
//! real crypto).
|
||||
//!
|
||||
//! Requires a live MongoDB; skips gracefully otherwise.
|
||||
|
||||
mod common;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
macro_rules! require_app {
|
||||
($app:expr) => {
|
||||
match $app {
|
||||
Some(x) => x,
|
||||
None => {
|
||||
eprintln!("[integration] skipped (MongoDB unavailable)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn unique_email() -> String {
|
||||
format!("test_{}@example.com", uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// Register a fully-set-up user (identity key + default self profile).
|
||||
async fn register_full(app: &axum::Router, email: &str) -> Value {
|
||||
let (status, body) = common::send_json(
|
||||
app,
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
Some(json!({
|
||||
"email": email,
|
||||
"username": email,
|
||||
"password": "supersecret",
|
||||
"identity_public_key": format!("pub-{email}"),
|
||||
"default_profile_name_data": "n",
|
||||
"default_profile_name_iv": "i",
|
||||
"default_wrapped_profile_dek": "owner-dek-v1",
|
||||
"default_wrapped_profile_dek_iv": "owner-dek-iv-v1",
|
||||
})),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201, "register_full failed, body: {body}");
|
||||
body
|
||||
}
|
||||
|
||||
fn token(body: &Value) -> String {
|
||||
body["token"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
fn self_profile_id(body: &Value) -> String {
|
||||
format!("profile_{}", body["user_id"].as_str().unwrap())
|
||||
}
|
||||
|
||||
/// Owner shares profile to recipient_email; returns the recipient's user_id
|
||||
/// (looked up from the share listing afterward).
|
||||
async fn share_to(
|
||||
app: &axum::Router,
|
||||
owner_token: &str,
|
||||
profile_id: &str,
|
||||
recipient_email: &str,
|
||||
) -> String {
|
||||
let (status, _) = common::send_json(
|
||||
app,
|
||||
"POST",
|
||||
&format!("/api/profiles/{profile_id}/shares"),
|
||||
Some(json!({
|
||||
"recipient_email": recipient_email,
|
||||
"ephemeral_public_key": format!("eph-{recipient_email}"),
|
||||
"wrapped_profile_dek": format!("wrap-{recipient_email}"),
|
||||
"wrapped_profile_dek_iv": "iv",
|
||||
"permissions": ["read"],
|
||||
})),
|
||||
Some(owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201, "share_to failed");
|
||||
// Look up the recipient's user_id from the listing.
|
||||
let (_, listing) = common::send_json(
|
||||
app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{profile_id}/shares"),
|
||||
None,
|
||||
Some(owner_token),
|
||||
)
|
||||
.await;
|
||||
listing
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|s| s["recipient_email"] == recipient_email)
|
||||
.unwrap()["recipient_user_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rekey_rotates_owner_share_and_keeps_listed_recipients() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let owner_body = register_full(&app, &unique_email()).await;
|
||||
let owner_token = token(&owner_body);
|
||||
let profile_id = self_profile_id(&owner_body);
|
||||
|
||||
let recipient_b = unique_email();
|
||||
let recipient_c = unique_email();
|
||||
register_full(&app, &recipient_b).await;
|
||||
register_full(&app, &recipient_c).await;
|
||||
let b_uid = share_to(&app, &owner_token, &profile_id, &recipient_b).await;
|
||||
let _c_uid = share_to(&app, &owner_token, &profile_id, &recipient_c).await;
|
||||
|
||||
// Owner rekeys: keeps B (fresh envelope), omits C (hard-revoke).
|
||||
let (status, body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/profiles/{profile_id}/rekey"),
|
||||
Some(json!({
|
||||
"new_wrapped_profile_dek": "owner-dek-v2",
|
||||
"new_wrapped_profile_dek_iv": "owner-dek-iv-v2",
|
||||
"recipient_envelopes": [{
|
||||
"recipient_user_id": b_uid,
|
||||
"ephemeral_public_key": "eph-b-v2",
|
||||
"wrapped_profile_dek": "wrap-b-v2",
|
||||
"wrapped_profile_dek_iv": "iv-v2",
|
||||
}],
|
||||
})),
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "rekey should return 200, body: {body}");
|
||||
// Owner share rotated.
|
||||
assert_eq!(body["wrapped_profile_dek"], "owner-dek-v2");
|
||||
assert_eq!(body["wrapped_profile_dek_iv"], "owner-dek-iv-v2");
|
||||
// Retained = [B].
|
||||
let retained: Vec<&str> = body["retained_recipient_user_ids"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(retained, vec![b_uid.as_str()]);
|
||||
|
||||
// The profile's stored owner-share reflects v2.
|
||||
let (_, profile_body) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{profile_id}"),
|
||||
None,
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(profile_body["wrapped_profile_dek"], "owner-dek-v2");
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rekey_hard_revokes_omitted_recipient() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let owner_body = register_full(&app, &unique_email()).await;
|
||||
let owner_token = token(&owner_body);
|
||||
let profile_id = self_profile_id(&owner_body);
|
||||
|
||||
let recipient_b = unique_email();
|
||||
let recipient_c = unique_email();
|
||||
let b_body = register_full(&app, &recipient_b).await;
|
||||
let c_body = register_full(&app, &recipient_c).await;
|
||||
let b_token = token(&b_body);
|
||||
let c_token = token(&c_body);
|
||||
let b_uid = share_to(&app, &owner_token, &profile_id, &recipient_b).await;
|
||||
let _c_uid = share_to(&app, &owner_token, &profile_id, &recipient_c).await;
|
||||
|
||||
// Before rekey: both recipients see the profile in shared-with-me.
|
||||
let (_, shared_before) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
"/api/profiles/shared-with-me",
|
||||
None,
|
||||
Some(&c_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
shared_before.as_array().unwrap().len(),
|
||||
1,
|
||||
"C should see the shared profile before rekey"
|
||||
);
|
||||
|
||||
// Rekey keeping only B.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/profiles/{profile_id}/rekey"),
|
||||
Some(json!({
|
||||
"new_wrapped_profile_dek": "owner-dek-v2",
|
||||
"new_wrapped_profile_dek_iv": "owner-dek-iv-v2",
|
||||
"recipient_envelopes": [{
|
||||
"recipient_user_id": b_uid,
|
||||
"ephemeral_public_key": "eph-b-v2",
|
||||
"wrapped_profile_dek": "wrap-b-v2",
|
||||
"wrapped_profile_dek_iv": "iv-v2",
|
||||
}],
|
||||
})),
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200);
|
||||
|
||||
// B (kept) still sees it, with the rotated envelope.
|
||||
let (_, shared_b) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
"/api/profiles/shared-with-me",
|
||||
None,
|
||||
Some(&b_token),
|
||||
)
|
||||
.await;
|
||||
let arr_b = shared_b.as_array().unwrap();
|
||||
assert_eq!(arr_b.len(), 1, "B sees exactly the one shared profile");
|
||||
assert_eq!(
|
||||
arr_b[0]["wrapped_profile_dek"], "wrap-b-v2",
|
||||
"B's envelope rotated"
|
||||
);
|
||||
|
||||
// C (omitted) no longer sees it.
|
||||
let (_, shared_c) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
"/api/profiles/shared-with-me",
|
||||
None,
|
||||
Some(&c_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
shared_c.as_array().unwrap().len(),
|
||||
0,
|
||||
"C must be hard-revoked from shared-with-me"
|
||||
);
|
||||
// And the share-gate 404s C on direct read.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{profile_id}"),
|
||||
None,
|
||||
Some(&c_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status, 404,
|
||||
"C must be denied by the gate after hard revoke"
|
||||
);
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rekey_rejects_envelope_for_recipient_with_no_share() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let owner_body = register_full(&app, &unique_email()).await;
|
||||
let owner_token = token(&owner_body);
|
||||
let profile_id = self_profile_id(&owner_body);
|
||||
|
||||
// A recipient who was never shared with.
|
||||
let stranger_body = register_full(&app, &unique_email()).await;
|
||||
let stranger_uid = stranger_body["user_id"].as_str().unwrap().to_string();
|
||||
|
||||
let (status, body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/profiles/{profile_id}/rekey"),
|
||||
Some(json!({
|
||||
"new_wrapped_profile_dek": "owner-dek-v2",
|
||||
"new_wrapped_profile_dek_iv": "owner-dek-iv-v2",
|
||||
"recipient_envelopes": [{
|
||||
"recipient_user_id": stranger_uid,
|
||||
"ephemeral_public_key": "x",
|
||||
"wrapped_profile_dek": "y",
|
||||
"wrapped_profile_dek_iv": "z",
|
||||
}],
|
||||
})),
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status, 400,
|
||||
"envelope for a recipient with no share must be rejected: {body}"
|
||||
);
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rekey_rejects_non_owner() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let owner_body = register_full(&app, &unique_email()).await;
|
||||
let profile_id = self_profile_id(&owner_body);
|
||||
|
||||
let other_token = token(®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;
|
||||
}
|
||||
|
|
@ -1,456 +0,0 @@
|
|||
//! Profile-sharing integration tests (Phase B).
|
||||
//!
|
||||
//! Exercises the X25519 envelope endpoints and the share-gate: an owner
|
||||
//! shares a profile to a recipient; the recipient sees it in
|
||||
//! /profiles/shared-with-me and can read its data; revoke cuts off access.
|
||||
//! All wrapped blobs are opaque strings — the server never inspects them, so
|
||||
//! the test doesn't need real crypto.
|
||||
//!
|
||||
//! Requires a live MongoDB; skips gracefully otherwise.
|
||||
|
||||
mod common;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
macro_rules! require_app {
|
||||
($app:expr) => {
|
||||
match $app {
|
||||
Some(x) => x,
|
||||
None => {
|
||||
eprintln!("[integration] skipped (MongoDB unavailable)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn unique_email() -> String {
|
||||
format!("test_{}@example.com", uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// URL-encode a value for a query string (emails contain '@').
|
||||
fn q(email: &str) -> String {
|
||||
email.replace('@', "%40")
|
||||
}
|
||||
|
||||
/// Register a fully-set-up user (identity public key + a default self profile)
|
||||
/// and return the access token. The owner profile is `profile_<user_id>`.
|
||||
async fn register_full(app: &axum::Router, email: &str) -> String {
|
||||
let (status, body) = common::send_json(
|
||||
app,
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
Some(json!({
|
||||
"email": email,
|
||||
"username": email,
|
||||
"password": "supersecret",
|
||||
"identity_public_key": format!("pubkey-{email}"),
|
||||
"default_profile_name_data": "name-blob",
|
||||
"default_profile_name_iv": "name-iv",
|
||||
"default_wrapped_profile_dek": "dek-blob",
|
||||
"default_wrapped_profile_dek_iv": "dek-iv",
|
||||
})),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201, "register_full failed, body: {body}");
|
||||
body["token"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Register a user with NO identity public key (simulates a pre-A1 account).
|
||||
async fn register_no_key(app: &axum::Router, email: &str) -> String {
|
||||
let (status, body) = common::send_json(
|
||||
app,
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
Some(json!({ "email": email, "username": email, "password": "supersecret" })),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201, "register_no_key failed, body: {body}");
|
||||
body["token"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// The owner's self profile id (deterministic contract).
|
||||
fn self_profile_id(owner_token_response: &Value) -> String {
|
||||
format!(
|
||||
"profile_{}",
|
||||
owner_token_response["user_id"].as_str().unwrap()
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_key_lookup_returns_recipient_identity_key() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let owner_email = unique_email();
|
||||
let _token = register_full(&app, &owner_email).await;
|
||||
|
||||
let (status, body) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/users/public-key?email={}", q(&owner_email)),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "public-key lookup, body: {body}");
|
||||
assert_eq!(body["identity_public_key"], format!("pubkey-{owner_email}"));
|
||||
assert!(!body["user_id"].as_str().unwrap().is_empty());
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_key_lookup_404s_for_unknown_or_keyless_user() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let keyless_email = unique_email();
|
||||
let _t = register_no_key(&app, &keyless_email).await;
|
||||
|
||||
// Unknown address.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
"/api/users/public-key?email=does-not-exist%40example.com",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404);
|
||||
|
||||
// Existing user but no key.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/users/public-key?email={}", q(&keyless_email)),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404);
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owner_shares_recipient_reads_owner_revokes() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let owner_email = unique_email();
|
||||
let recipient_email = unique_email();
|
||||
|
||||
// Register owner + recipient (both with identity keys + self profiles).
|
||||
let (_, owner_body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
Some(json!({
|
||||
"email": owner_email,
|
||||
"username": owner_email,
|
||||
"password": "supersecret",
|
||||
"identity_public_key": "owner-pub",
|
||||
"default_profile_name_data": "n", "default_profile_name_iv": "i",
|
||||
"default_wrapped_profile_dek": "d", "default_wrapped_profile_dek_iv": "v",
|
||||
})),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let owner_token = owner_body["token"].as_str().unwrap().to_string();
|
||||
let owner_profile = self_profile_id(&owner_body);
|
||||
|
||||
let recipient_token = register_full(&app, &recipient_email).await;
|
||||
|
||||
// Owner shares their self profile to the recipient.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/profiles/{owner_profile}/shares"),
|
||||
Some(json!({
|
||||
"recipient_email": recipient_email,
|
||||
"ephemeral_public_key": "ephemeral-pub",
|
||||
"wrapped_profile_dek": "wrapped-dek",
|
||||
"wrapped_profile_dek_iv": "wrapped-iv",
|
||||
"permissions": ["read"],
|
||||
})),
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201, "share create should return 201");
|
||||
|
||||
// Recipient sees it under /profiles/shared-with-me.
|
||||
let (status, body) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
"/api/profiles/shared-with-me",
|
||||
None,
|
||||
Some(&recipient_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "shared-with-me, body: {body}");
|
||||
let arr = body.as_array().unwrap();
|
||||
assert_eq!(arr.len(), 1);
|
||||
assert_eq!(arr[0]["profile_id"], owner_profile);
|
||||
assert_eq!(arr[0]["ephemeral_public_key"], "ephemeral-pub");
|
||||
assert_eq!(arr[0]["wrapped_profile_dek"], "wrapped-dek");
|
||||
|
||||
// Recipient can GET the shared profile (share-aware GET).
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{owner_profile}"),
|
||||
None,
|
||||
Some(&recipient_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "recipient should read shared profile");
|
||||
|
||||
// Recipient can list the owner's medications for that profile (the owner
|
||||
// has none, but the share-gate must admit the request rather than 404).
|
||||
let (status, body) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/medications?profile_id={owner_profile}"),
|
||||
None,
|
||||
Some(&recipient_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "recipient meds read, body: {body}");
|
||||
assert_eq!(body.as_array().unwrap().len(), 0);
|
||||
|
||||
// Recipient attempts a write to the shared profile. The medication create
|
||||
// handler (a) doesn't enforce ownership on writes yet (issue #12) and
|
||||
// (b) returns 200 OK (not 201) on success. So the write currently succeeds
|
||||
// but is attributed to the RECIPIENT's user_id, not the owner — meaning it
|
||||
// does NOT pollute the owner's view of that profile. Accept 200 (current,
|
||||
// #12 not fixed) or 403 (once #12 lands). Either way the owner's records
|
||||
// must be untouched.
|
||||
let (write_status, _body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
"/api/medications",
|
||||
Some(json!({
|
||||
"profile_id": owner_profile,
|
||||
"encrypted_data": { "data": "x", "iv": "y" },
|
||||
"active": true,
|
||||
})),
|
||||
Some(&recipient_token),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
write_status == 200 || write_status == 403,
|
||||
"write outcome: {write_status}"
|
||||
);
|
||||
let (status, owner_meds) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/medications?profile_id={owner_profile}"),
|
||||
None,
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200);
|
||||
assert_eq!(
|
||||
owner_meds.as_array().unwrap().len(),
|
||||
0,
|
||||
"owner's data untouched"
|
||||
);
|
||||
|
||||
// Owner revokes.
|
||||
// Look up the recipient's user id from the shares listing.
|
||||
let (_, shares) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{owner_profile}/shares"),
|
||||
None,
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
let recipient_uid = shares[0]["recipient_user_id"].as_str().unwrap().to_string();
|
||||
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"DELETE",
|
||||
&format!("/api/profiles/{owner_profile}/shares/{recipient_uid}"),
|
||||
None,
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 204, "revoke should return 204");
|
||||
|
||||
// Recipient now loses read access.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{owner_profile}"),
|
||||
None,
|
||||
Some(&recipient_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404, "revoked recipient should get 404");
|
||||
let (status, body) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
"/api/profiles/shared-with-me",
|
||||
None,
|
||||
Some(&recipient_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200);
|
||||
assert_eq!(
|
||||
body.as_array().unwrap().len(),
|
||||
0,
|
||||
"shared-with-me now empty"
|
||||
);
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn share_rejects_bad_recipient_states() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
let owner_email = unique_email();
|
||||
|
||||
let (_, owner_body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
Some(json!({
|
||||
"email": owner_email, "username": owner_email, "password": "supersecret",
|
||||
"identity_public_key": "owner-pub",
|
||||
"default_profile_name_data": "n", "default_profile_name_iv": "i",
|
||||
"default_wrapped_profile_dek": "d", "default_wrapped_profile_dek_iv": "v",
|
||||
})),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let owner_token = owner_body["token"].as_str().unwrap().to_string();
|
||||
let owner_profile = self_profile_id(&owner_body);
|
||||
|
||||
// Share to a nonexistent recipient → 404.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/profiles/{owner_profile}/shares"),
|
||||
Some(json!({
|
||||
"recipient_email": "ghost@example.com",
|
||||
"ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v",
|
||||
})),
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404, "share to ghost should 404");
|
||||
|
||||
// Share to a user without an identity key → 404.
|
||||
let keyless_email = unique_email();
|
||||
let _t = register_no_key(&app, &keyless_email).await;
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/profiles/{owner_profile}/shares"),
|
||||
Some(json!({
|
||||
"recipient_email": keyless_email,
|
||||
"ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v",
|
||||
})),
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404, "share to keyless user should 404");
|
||||
|
||||
// Share to self → 400.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/profiles/{owner_profile}/shares"),
|
||||
Some(json!({
|
||||
"recipient_email": owner_email,
|
||||
"ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v",
|
||||
})),
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 400, "share to self should 400");
|
||||
|
||||
// Non-owner cannot create a share for someone else's profile.
|
||||
let other_token = register_full(&app, &unique_email()).await;
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/profiles/{owner_profile}/shares"),
|
||||
Some(json!({
|
||||
"recipient_email": unique_email(),
|
||||
"ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v",
|
||||
})),
|
||||
Some(&other_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status, 404,
|
||||
"non-owner share attempt should 404 (profile not found for them)"
|
||||
);
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_share_is_treated_as_absent() {
|
||||
let (app, db_name) = require_app!(common::app_for_test().await);
|
||||
|
||||
let (_, owner_body) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
Some(json!({
|
||||
"email": unique_email(), "username": "owner", "password": "supersecret",
|
||||
"identity_public_key": "owner-pub",
|
||||
"default_profile_name_data": "n", "default_profile_name_iv": "i",
|
||||
"default_wrapped_profile_dek": "d", "default_wrapped_profile_dek_iv": "v",
|
||||
})),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let owner_token = owner_body["token"].as_str().unwrap().to_string();
|
||||
let owner_profile = self_profile_id(&owner_body);
|
||||
|
||||
let recipient_email = unique_email();
|
||||
let recipient_token = register_full(&app, &recipient_email).await;
|
||||
|
||||
// Share with an expiry in the past.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"POST",
|
||||
&format!("/api/profiles/{owner_profile}/shares"),
|
||||
Some(json!({
|
||||
"recipient_email": recipient_email,
|
||||
"ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v",
|
||||
"expires_at": "2020-01-01T00:00:00Z",
|
||||
})),
|
||||
Some(&owner_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201);
|
||||
|
||||
// Recipient cannot read — expired share is invisible to the gate.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
&format!("/api/profiles/{owner_profile}"),
|
||||
None,
|
||||
Some(&recipient_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404, "expired share should not grant access");
|
||||
let (status, body) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
"/api/profiles/shared-with-me",
|
||||
None,
|
||||
Some(&recipient_token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200);
|
||||
assert_eq!(
|
||||
body.as_array().unwrap().len(),
|
||||
0,
|
||||
"expired share hidden from list"
|
||||
);
|
||||
|
||||
common::drop_test_db(&db_name).await;
|
||||
}
|
||||
|
|
@ -179,7 +179,6 @@ async fn health_stat_stored_as_ciphertext() {
|
|||
"POST",
|
||||
"/api/health-stats",
|
||||
Some(json!({
|
||||
"profile_id": "default",
|
||||
"encrypted_data": { "data": opaque_data, "iv": "aXY=" },
|
||||
"recorded_at": "2026-07-01T10:00:00Z",
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -11,15 +11,14 @@ the documentation reconciliation. They are historical context, not current specs
|
|||
|--------|-------|
|
||||
| [tech-stack-decision.md](./tech-stack-decision.md) | Master stack choice: Rust/Axum backend, React frontend, MongoDB, JWT |
|
||||
| [mongodb-schema-decision.md](./mongodb-schema-decision.md) | Document model + at-rest encryption approach |
|
||||
| [jwt-authentication-decision.md](./jwt-authentication-decision.md) | JWT access/refresh tokens, `token_version` revocation, refresh rotation — *reconciled with code 2026-07-18* |
|
||||
| [jwt-authentication-decision.md](./jwt-authentication-decision.md) | JWT access/refresh tokens, recovery phrases |
|
||||
| [frontend-decision-summary.md](./frontend-decision-summary.md) | React (web) + React Native (mobile, future) split |
|
||||
| [state-management-decision.md](./state-management-decision.md) | Client state — *superseded*: decision was Redux Toolkit, **actual code uses Zustand** |
|
||||
| [monorepo-structure.md](./monorepo-structure.md) | Repository layout (`backend/`, `web/`, `mobile/`, `docs/`) |
|
||||
| [backend-deployment-constraints.md](./backend-deployment-constraints.md) | Deployment requirements (Solaria, Docker) |
|
||||
| [mobile-health-frameworks-data.md](./mobile-health-frameworks-data.md) | HealthKit / Health Connect data-type reference (for future mobile work) |
|
||||
| [android-health-connect-data-types.md](./android-health-connect-data-types.md) | Android Health Connect data types |
|
||||
| [zero-knowledge-encryption.md](./zero-knowledge-encryption.md) | Client-side zero-knowledge encryption (AES-256-GCM, double-PBKDF2, wrapped-DEK recovery; Phase 1 + Phase 2 implemented) |
|
||||
| [multi-person-sharing.md](./multi-person-sharing.md) | Per-profile DEK + X25519 envelope for family/caregiver sharing — *Decided, not yet implemented* (issue #3) |
|
||||
| [zero-knowledge-encryption.md](./zero-knowledge-encryption.md) | Client-side zero-knowledge encryption (AES-256-GCM, double-PBKDF2, Phase 1) |
|
||||
|
||||
> **Note**: Where a decision diverges from the implemented code (e.g. state
|
||||
> management), the code is the source of truth and the ADR is kept only as
|
||||
|
|
|
|||
|
|
@ -1,185 +1,174 @@
|
|||
# ADR: JWT Authentication
|
||||
# JWT Authentication Decision Summary
|
||||
|
||||
**Status**: Implemented (current code is the source of truth)
|
||||
**Date**: 2026-02-14 (original decision), 2026-07-18 (reconciled with code)
|
||||
**Date**: 2026-02-14
|
||||
**Decision**: **JWT with Refresh Tokens + Recovery Phrases**
|
||||
|
||||
> This ADR was originally written during Phase 1 research and described several
|
||||
> things that were never built (bcrypt, Redis, family-role permission claims in
|
||||
> the JWT, the specific recovery-phrase flow). It has been rewritten to match
|
||||
> the implementation. The original reasoning is preserved in the "History"
|
||||
> section at the end. Where code and the old text disagree, **the code wins**.
|
||||
---
|
||||
|
||||
## Context
|
||||
## Authentication Strategy
|
||||
|
||||
Normogen authenticates accounts with email + password and authorizes API
|
||||
requests with stateless JWTs. This sits *underneath* the zero-knowledge
|
||||
encryption layer (see [`zero-knowledge-decryption.md`](./zero-knowledge-encryption.md)
|
||||
and [`encryption.md`](../product/encryption.md)): authentication proves who you
|
||||
are and lets you fetch your wrapped DEK; the client-side ZK layer then unlocks
|
||||
your data. The auth system never sees plaintext health data.
|
||||
### Primary: JWT (JSON Web Tokens)
|
||||
|
||||
## Decision
|
||||
**Why JWT?**
|
||||
- Stateless design scales to 1000+ concurrent connections
|
||||
- Works perfectly with mobile apps (AsyncStorage)
|
||||
- No server-side session storage needed
|
||||
- Easy to scale Axum horizontally
|
||||
|
||||
### Token model
|
||||
### Token Types
|
||||
|
||||
Two JWTs, both HS256-signed with a shared secret from config:
|
||||
**Access Token** (15 minutes)
|
||||
- Used for API requests
|
||||
- Short-lived for security
|
||||
- Contains: user_id, email, family_id, permissions
|
||||
|
||||
| Token | Lifetime | Claims | Purpose |
|
||||
|---|---|---|---|
|
||||
| Access | configurable (`JwtConfig.access_token_expiry_minutes`, default **15 min**) | `{sub, exp, iat, user_id, email, token_version}` | Sent on every API request |
|
||||
| Refresh | configurable (`JwtConfig.refresh_token_expiry_days`, default **30 days**) | `{sub, exp, iat, jti, user_id, token_version}` | Exchange for a new access token |
|
||||
**Refresh Token** (30 days)
|
||||
- Used to get new access tokens
|
||||
- Long-lived for convenience
|
||||
- Stored in MongoDB for revocation
|
||||
- Rotated on every refresh
|
||||
|
||||
> Reference: `backend/src/auth/jwt.rs` (`Claims`, `RefreshClaims`, `JwtService`).
|
||||
---
|
||||
|
||||
The lifetimes are **config-driven**, not hardcoded — the original ADR's "15 min
|
||||
/ 30 days" are the defaults, not constants.
|
||||
## Token Revocation Strategies
|
||||
|
||||
### Claims (actual, as implemented)
|
||||
### 1. Refresh Token Blacklist (Recommended) ⭐
|
||||
- Store refresh tokens in MongoDB
|
||||
- Mark as revoked on logout
|
||||
- Check on every refresh
|
||||
|
||||
The earlier ADR listed richer claims (`family_id`, `permissions`, `token_type`).
|
||||
**These are not in the shipped access token.** The real `Claims` struct is:
|
||||
### 2. Token Versioning
|
||||
- Include version in JWT claims
|
||||
- Increment on password change
|
||||
- Invalidate all tokens when version changes
|
||||
|
||||
```rust
|
||||
pub struct Claims { // access token
|
||||
pub sub: String, // = user_id (ObjectId hex)
|
||||
pub exp: usize,
|
||||
pub iat: usize,
|
||||
pub user_id: String,
|
||||
pub email: String,
|
||||
pub token_version: i32,
|
||||
}
|
||||
### 3. Access Token Blacklist (Optional)
|
||||
- Store revoked access tokens in Redis
|
||||
- For immediate revocation
|
||||
- Auto-expires with TTL
|
||||
|
||||
pub struct RefreshClaims { // refresh token
|
||||
pub sub: String,
|
||||
pub exp: usize,
|
||||
pub iat: usize,
|
||||
pub jti: String, // unique per token (see below)
|
||||
pub user_id: String,
|
||||
pub token_version: i32,
|
||||
---
|
||||
|
||||
## Refresh Token Pattern
|
||||
|
||||
### Token Rotation (Security Best Practice) ⭐
|
||||
|
||||
**Flow**:
|
||||
1. Client sends refresh_token
|
||||
2. Server verifies refresh_token (not revoked, not expired)
|
||||
3. Server generates new access_token
|
||||
4. Server generates new refresh_token
|
||||
5. Server revokes old refresh_token
|
||||
6. Server returns new tokens
|
||||
|
||||
**Why?** Prevents reuse of stolen refresh tokens
|
||||
|
||||
---
|
||||
|
||||
## Zero-Knowledge Password Recovery
|
||||
|
||||
### Recovery Phrases (from encryption.md)
|
||||
|
||||
**Registration**:
|
||||
1. Client generates recovery phrase (random 32 bytes)
|
||||
2. Client encrypts recovery phrase with password
|
||||
3. Client sends: email, password hash, encrypted recovery phrase
|
||||
4. Server stores: email, password hash, encrypted recovery phrase
|
||||
|
||||
**Password Recovery**:
|
||||
1. User requests recovery (enters email)
|
||||
2. Server returns: encrypted recovery phrase
|
||||
3. Client decrypts with recovery key (user enters manually)
|
||||
4. User enters new password
|
||||
5. Client re-encrypts recovery phrase with new password
|
||||
6. Client sends: new password hash, re-encrypted recovery phrase
|
||||
7. Server updates: password hash, encrypted recovery phrase, token_version + 1
|
||||
8. All existing tokens invalidated (version mismatch)
|
||||
|
||||
---
|
||||
|
||||
## Family Member Access Control
|
||||
|
||||
### Permissions in JWT
|
||||
|
||||
```typescript
|
||||
// JWT permissions based on family role
|
||||
{
|
||||
"parent": [
|
||||
"read:own_data",
|
||||
"write:own_data",
|
||||
"read:family_data",
|
||||
"write:family_data",
|
||||
"manage:family_members",
|
||||
"delete:data"
|
||||
],
|
||||
"child": [
|
||||
"read:own_data",
|
||||
"write:own_data"
|
||||
],
|
||||
"elderly": [
|
||||
"read:own_data",
|
||||
"write:own_data",
|
||||
"read:family_data"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Note what is **absent** and why:
|
||||
### Permission Middleware
|
||||
|
||||
- **No `family_id` or `permissions` in the token.** Family/permission
|
||||
enforcement is not implemented at the auth layer (see issue #3 — multi-person
|
||||
sharing is an open design problem). Putting unenforced claims in the token
|
||||
would imply protection that doesn't exist.
|
||||
- (Historical: an orphaned `backend/src/auth/claims.rs` once defined a
|
||||
duplicate `AccessClaims` struct with `family_id`/`permissions`/`jti`. It
|
||||
was dead code — never declared as a module, never imported — and has been
|
||||
removed; see issue #5.)
|
||||
- **No `token_type` discriminator.** Access and refresh claims are different
|
||||
structs, so a token can only decode as one or the other — the field is
|
||||
redundant and was dropped.
|
||||
- Check permissions on protected routes
|
||||
- Return 403 Forbidden if insufficient permissions
|
||||
- Works with JWT claims
|
||||
|
||||
### Refresh tokens: persistence, rotation, reuse detection
|
||||
---
|
||||
|
||||
- Refresh tokens are **stored in MongoDB** (`refresh_tokens` collection) — not
|
||||
Redis (the original ADR listed Redis as an option; it is not used).
|
||||
- The `jti` claim makes every refresh token unique. Without it, two refresh
|
||||
tokens issued in the same second for the same user would be byte-identical,
|
||||
which breaks rotation and reuse detection. (Covered by the
|
||||
`refresh_tokens_are_unique_even_in_same_second` test in `jwt.rs`.)
|
||||
- On refresh: validate signature + expiry, confirm the token exists and is not
|
||||
revoked in the repository, mint a new access + refresh pair, revoke the old
|
||||
refresh token (rotation).
|
||||
## Technology Stack
|
||||
|
||||
### Token revocation: versioning + session management
|
||||
### Backend (Axum)
|
||||
- jsonwebtoken 9.x (JWT crate)
|
||||
- bcrypt 0.15 (password hashing)
|
||||
- mongodb 3.0 (refresh token storage)
|
||||
- redis (optional, for access token blacklist)
|
||||
|
||||
Two mechanisms, both implemented:
|
||||
### Client (React Native + React)
|
||||
- AsyncStorage (token storage)
|
||||
- axios (API client with JWT interceptor)
|
||||
- PBKDF2 (password derivation)
|
||||
- AES-256-GCM (data encryption)
|
||||
|
||||
1. **`token_version` (global kill-switch).** Stored on the `User` document,
|
||||
incremented on password change and password recovery. The JWT middleware
|
||||
(`middleware/auth.rs`) rejects any access token whose `token_version` is
|
||||
stale against the user's current value. A short-lived `TokenVersionCache`
|
||||
avoids hitting Mongo on every request.
|
||||
2. **Session management.** Refresh tokens are tracked as sessions
|
||||
(`GET /api/sessions`, `DELETE /api/sessions/:id`, `DELETE /api/sessions/all`)
|
||||
so a user can list and revoke individual logins.
|
||||
---
|
||||
|
||||
### Password handling
|
||||
## Implementation Timeline
|
||||
|
||||
- **PBKDF2** (not bcrypt — the original ADR mentioned bcrypt), via the `pbkdf2`
|
||||
crate with a per-password random salt. (`backend/src/auth/password.rs`)
|
||||
- Under the ZK model, what the server hashes is the **auth secret** — a
|
||||
base64'd PBKDF2 derivation of the user's password produced client-side — not
|
||||
the raw password. The raw password never leaves the browser. See
|
||||
[`encryption.md`](../product/encryption.md) §2.
|
||||
- **Week 1**: Basic JWT (login, register, middleware)
|
||||
- **Week 1-2**: Refresh tokens (storage, rotation)
|
||||
- **Week 2**: Token revocation (blacklist, versioning)
|
||||
- **Week 2-3**: Password recovery (recovery phrases)
|
||||
- **Week 3**: Family access control (permissions)
|
||||
- **Week 3-4**: Security hardening (rate limiting, HTTPS)
|
||||
|
||||
### Password recovery
|
||||
**Total**: 3-4 weeks
|
||||
|
||||
Recovery is handled by the **zero-knowledge wrapped-DEK** mechanism, *not* by
|
||||
the "client encrypts the recovery phrase with the password" scheme the original
|
||||
ADR described. The client derives a recovery KEK from the recovery phrase,
|
||||
unwraps the DEK from the recovery-wrapped form, and re-wraps it under the new
|
||||
password. See [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md)
|
||||
§"Phase 2: Wrapped-DEK Recovery". The server bumps `token_version` on recovery,
|
||||
invalidating all prior tokens.
|
||||
---
|
||||
|
||||
### What protects what
|
||||
## Next Steps
|
||||
|
||||
Auth + the ciphertext store are defended server-side; plaintext health data is
|
||||
never on the server to protect:
|
||||
1. Implement basic JWT service in Axum
|
||||
2. Create MongoDB schema for users and refresh tokens
|
||||
3. Implement login/register/refresh/logout handlers
|
||||
4. Create JWT middleware for protected routes
|
||||
5. Implement token revocation (blacklist + versioning)
|
||||
6. Integrate password recovery (from encryption.md)
|
||||
7. Implement family access control (permissions)
|
||||
8. Test entire authentication flow
|
||||
9. Create client-side authentication (React Native + React)
|
||||
|
||||
| Layer | Mechanism |
|
||||
|---|---|
|
||||
| Password / auth secret | PBKDF2 + per-password salt |
|
||||
| Tokens | HS256 signature, short access TTL, refresh rotation + `jti` reuse detection |
|
||||
| Revocation | `token_version` (global) + session list (per-login) |
|
||||
| Brute force | Account lockout (5 attempts → exponential backoff) + rate limiting |
|
||||
| Forensics | Audit logging (auth attempts, authz checks) |
|
||||
|
||||
> Reference: `backend/src/auth/`, `backend/src/security/`, `backend/src/middleware/`.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Stateless access tokens scale horizontally (no DB lookup per request except
|
||||
the cached version check).
|
||||
- `token_version` gives instant global revocation on password change/recovery
|
||||
without maintaining a blocklist of outstanding tokens.
|
||||
- Refresh rotation + `jti` detects token theft: a rotated-away token being
|
||||
presented again signals reuse.
|
||||
|
||||
### Negative / costs
|
||||
|
||||
- `token_version` revocation is **coarse** — it kills *all* of a user's
|
||||
sessions at once. Per-session revocation is only available via the session
|
||||
list (refresh-token deletion).
|
||||
- HS256 with a shared secret means the secret is sensitive infrastructure; a
|
||||
key rotation requires invalidating all tokens (acceptable for the deployment
|
||||
model, but worth noting vs. asymmetric RS/ES keys).
|
||||
|
||||
## Open items
|
||||
|
||||
- ~~Remove `backend/src/auth/claims.rs` (dead `AccessClaims`) or wire it up
|
||||
intentionally.~~ **Done** — the orphaned file has been deleted (issue #5,
|
||||
PR #<to fill after open>). The live `Claims` / `RefreshClaims` structs in
|
||||
`backend/src/auth/jwt.rs` are the only ones.
|
||||
- When issue #3 (multi-person sharing) is decided, revisit whether family /
|
||||
share authorization belongs in the JWT or is enforced per-request against a
|
||||
shares collection.
|
||||
|
||||
## History (original 2026-02-14 decision, superseded where noted)
|
||||
|
||||
The original ADR proposed: JWT with refresh tokens; bcrypt; optional Redis for
|
||||
access-token blacklists; recovery by client-encrypting the recovery phrase with
|
||||
the password; and `family_id`/`permissions` claims with parent/child/elderly
|
||||
role matrices. Of these, only **JWT with refresh tokens + rotation** was built
|
||||
as described. Password hashing uses PBKDF2 (not bcrypt), there is no Redis,
|
||||
recovery uses the wrapped-DEK scheme (not phrase-encryption), and the family/
|
||||
permission claims were never wired into the real `Claims`. This section is kept
|
||||
as a record of the original reasoning; the sections above are authoritative.
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Code: `backend/src/auth/jwt.rs`, `backend/src/auth/password.rs`,
|
||||
`backend/src/middleware/auth.rs`, `backend/src/auth/token_version_cache.rs`,
|
||||
`backend/src/models/refresh_token.rs`, `backend/src/models/session.rs`
|
||||
- Related ADRs: [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md),
|
||||
[`multi-person-sharing.md`](./multi-person-sharing.md) (draft)
|
||||
- Product doc: [`encryption.md`](../product/encryption.md)
|
||||
- Related issues: [#3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3),
|
||||
[#4](https://gitea.soliverez.com.ar/alvaro/normogen/issues/4)
|
||||
- [Comprehensive JWT Research](./2026-02-14-jwt-authentication-research.md)
|
||||
- [Normogen Encryption Guide](../encryption.md)
|
||||
- [JWT RFC 7519](https://tools.ietf.org/html/rfc7519)
|
||||
- [Axum JWT Guide](https://docs.rs/axum/latest/axum/)
|
||||
- [OWASP JWT Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html)
|
||||
|
|
|
|||
|
|
@ -1,336 +0,0 @@
|
|||
# ADR: Multi-Person Sharing Under Zero-Knowledge
|
||||
|
||||
**Status**: Decided (not yet implemented)
|
||||
**Date**: 2026-07-18
|
||||
**Discussion**: [issue #3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3)
|
||||
**Related**: [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md) (the single-user ZK model this extends)
|
||||
|
||||
> Records the design decision and its rationale ahead of implementation,
|
||||
> following the pattern of `zero-knowledge-encryption.md`. The five original
|
||||
> open questions were resolved on 2026-07-18 (see "Open questions" section).
|
||||
> Two deferred items remain (pubkey verification mechanism; admin re-key
|
||||
> quorum) — neither blocks implementation. All crypto primitives named here
|
||||
> are available in the browser's Web Crypto API.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Normogen's core product vision (see
|
||||
[`docs/product/PERSONA_AND_FAMILY_MANAGEMENT.md`](../product/PERSONA_AND_FAMILY_MANAGEMENT.md))
|
||||
is multi-person health data: a parent manages a child's medications and shares
|
||||
them with a spouse or caregiver. The existing zero-knowledge model
|
||||
(`zero-knowledge-encryption.md`) is single-user: **all of a user's data is
|
||||
encrypted under one DEK that only that user can unwrap.** There is no mechanism
|
||||
for a second user to decrypt any of it. As called out in issue #3, the current
|
||||
`Share` model + `/api/shares` routes store ACL-style permission records but
|
||||
cannot grant decryption access to another account — every medication blob is
|
||||
under the owner's DEK, and the recipient has a different one.
|
||||
|
||||
The product requirements that constrain this design (confirmed in issue #3):
|
||||
|
||||
1. **Parents and spouses have separate logins**, and may belong to *different*
|
||||
families. Divorced parents with their own families must be able to share the
|
||||
profile of their common child **and nothing else**.
|
||||
2. **Recipients always have Normogen accounts.** (One-way document dumps to
|
||||
non-accounts — e.g. a physician via an encrypted PDF — is a separate,
|
||||
simpler feature, out of scope here.)
|
||||
3. **Revocation is a hard requirement.** Cutting off a caregiver must take
|
||||
effect.
|
||||
4. **The server operator is fully untrusted.** Key exchange cannot rely on the
|
||||
server brokering symmetric secrets.
|
||||
|
||||
Additional use cases from issue #3 that the model must accommodate:
|
||||
|
||||
- **Profile graduation.** A child profile eventually becomes an independent
|
||||
user with their own login, who may then revoke the parents' access.
|
||||
- **Pets.** A profile may be an animal (medications, labs, etc.), not a human.
|
||||
|
||||
## Decision
|
||||
|
||||
Implement **per-profile DEKs with an X25519 asymmetric envelope** for sharing,
|
||||
keeping the server a fully blind store. Concretely:
|
||||
|
||||
### 1. Key hierarchy
|
||||
|
||||
```
|
||||
password KEK ──wraps──▶ account DEK ──wraps each──▶ profile DEK ──encrypts──▶ profile data
|
||||
│ (one per profile)
|
||||
└──also wraps──▶ account X25519 private key
|
||||
(account X25519 public key stored plaintext)
|
||||
```
|
||||
|
||||
- **Account DEK**: random AES-256-GCM key, wrapped under the password KEK
|
||||
(unchanged from today). At login the client unwraps it and holds it in
|
||||
memory.
|
||||
- **Profile DEK**: a *new* random AES-256-GCM key **per profile**. All data
|
||||
belonging to a profile (its medications, appointments, etc.) is encrypted
|
||||
under the profile DEK, not the account DEK. Each profile DEK is wrapped under
|
||||
the account DEK and stored on the server, indexed by `profileId`.
|
||||
- **Account X25519 keypair**: generated at registration. The **private** half
|
||||
is wrapped under the account DEK (so it unlocks at login); the **public**
|
||||
half is stored in plaintext — public keys are not secret.
|
||||
|
||||
This adds one tier to today's flat `account DEK → data`. Rationale in
|
||||
Consequences (§3-tier vs flat).
|
||||
|
||||
### 2. Ownership and access
|
||||
|
||||
- Each **profile** has exactly one **owning account** at a time. The owner
|
||||
holds the profile DEK wrapped to their account DEK (the *owner share*).
|
||||
- **Shared access** = the profile DEK wrapped to another account's X25519
|
||||
public key. Stored on the server as a *recipient share*, indexed by
|
||||
`(profileId, recipientUserId)`.
|
||||
- To **read** a shared profile, the recipient's client (at login) unwraps their
|
||||
X25519 private key, ECDH-derives a wrapping key against the share's ephemeral
|
||||
public key, and unwraps the profile DEK.
|
||||
|
||||
This is the standard asymmetric-envelope pattern (cf. Signal, `age`,
|
||||
Tarsnap). The server only ever stores wrapped blobs and public keys.
|
||||
|
||||
### 3. Sharing flow (owner → recipient)
|
||||
|
||||
Client-side only, server untrusted:
|
||||
|
||||
1. Owner's client looks up the **recipient's X25519 public key**
|
||||
(`GET /api/users/:id/public-key`, new).
|
||||
2. Owner's client unwraps the target profile's DEK (via their account DEK).
|
||||
3. Generate an **ephemeral X25519 keypair** for this share.
|
||||
4. ECDH(ephemeral priv, recipient pub) → AES-GCM **wrapping key**.
|
||||
5. Wrap the profile DEK under that wrapping key → `{ciphertext, iv, ephemeralPubKey}`.
|
||||
6. `POST /api/profiles/:id/shares` with `{recipientUserId, wrappedDek, ephemeralPubKey, permissions, expiresAt?}`.
|
||||
7. Server stores the share record. It cannot decrypt it.
|
||||
|
||||
### 4. Revocation
|
||||
|
||||
Two layers, in order of strength:
|
||||
|
||||
- **Soft revoke** (cheap): delete or deactivate the recipient share record. The
|
||||
recipient's client no longer receives the wrapped DEK from the server, so it
|
||||
cannot decrypt **new reads**. Caveat: a malicious client that cached the
|
||||
profile DEK before revocation keeps decrypting data it already fetched.
|
||||
- **Hard revoke / re-key** (strong): owner generates a **new profile DEK**,
|
||||
re-encrypts all of the profile's data under it, re-wraps to the owner share
|
||||
and to all *still-valid* recipient shares. A revoked recipient's cached old
|
||||
DEK now fails to decrypt any future fetch. Required for the "kid revokes
|
||||
parent" graduation case where you must assume the parent may have cached
|
||||
data.
|
||||
|
||||
Hard revocation is the answer to requirement (3). Re-keying is expensive
|
||||
(linear in profile data size) so it is opt-in / triggered explicitly, not the
|
||||
default for every revoke.
|
||||
|
||||
### 5. Profile model changes
|
||||
|
||||
To support pets and graduation, the current `role: "patient"` string becomes:
|
||||
|
||||
```
|
||||
kind: "human" | "pet" // distinguishes people from animals
|
||||
relationship: string // freeform: "self", "child", "spouse", "pet", ...
|
||||
```
|
||||
|
||||
Ownership-transfer (graduation) is **not new crypto** — it is the existing
|
||||
share machinery plus an `owner_account_id` field swap:
|
||||
|
||||
1. New adult account (the graduated child) generates its X25519 keypair.
|
||||
2. Old owner wraps the profile DEK to the new account's pubkey as a share, and
|
||||
marks that share as the new **owner share**.
|
||||
3. Old owner's access is downgraded to (or removed as) a normal recipient share.
|
||||
4. The new owner can revoke any remaining parent shares via soft/hard revoke.
|
||||
|
||||
### 5b. Use case: elderly parent cared for by children
|
||||
|
||||
A key motivation for the profile/account split is that **profile ownership and
|
||||
account login are independent.** The elderly-care scenario has three setups,
|
||||
all covered:
|
||||
|
||||
- **Parent self-manages, shares to children.** Parent owns their own account
|
||||
and `self` profile; creates recipient shares to each child. Standard
|
||||
share-to-another-account flow. Any party can revoke.
|
||||
- **Managing child holds a dedicated parent account.** A separate account
|
||||
exists for the parent (the identity of record), but the managing child holds
|
||||
or controls the password and shares the profile to siblings. Cleaner than
|
||||
embedding the parent as a profile inside the child's account, and preserves
|
||||
the "take over later" path: if the parent becomes able (or, conversely,
|
||||
passes away), it's the same ownership machinery.
|
||||
- **Parent modeled as a profile inside the managing child's account.** No
|
||||
separate login for the parent; the child owns the profile and shares it.
|
||||
Simplest, but the parent has no independent identity and cannot revoke
|
||||
themselves. Recommend against this when the parent is or may become an
|
||||
independent actor.
|
||||
|
||||
**Death/incapacity of the sole-account owner** is the one wrinkle that needs
|
||||
an explicit decision: see "Permissions and the admin-share question" below.
|
||||
|
||||
### 5c. Permissions and the admin-share question
|
||||
|
||||
The `permissions` list on a recipient share (Open question 3) raises a
|
||||
sub-decision: **does an `admin` permission let a recipient act as a quasi-owner
|
||||
without the original owner?** Specifically: re-share the profile, re-key it
|
||||
(hard revoke), and add/remove other shares.
|
||||
|
||||
This matters for two scenarios:
|
||||
|
||||
- **Graduation in reverse / death of the owner.** If the parent owns the
|
||||
account and becomes incapacitated, no child can write, re-share, or revoke —
|
||||
those require the owner's account DEK, which is gone with the password.
|
||||
- **Operational delegation.** A primary caregiver wants to delegate admin
|
||||
tasks without sharing their password.
|
||||
|
||||
**Decision: support an `admin` permission tier.** An admin share can re-share,
|
||||
re-key, and manage other shares on a profile. This keeps the owner-DEK model
|
||||
intact (the owner share is still special — it can *transfer* ownership, which
|
||||
admin cannot) while handling the death/incapacity and delegation cases without
|
||||
key escrow. Hard revocation (re-key) by an admin rotates the profile DEK and
|
||||
re-wraps to all *still-valid* shares, same as owner-initiated re-key.
|
||||
|
||||
> ❓ **Open sub-item:** when an admin re-keys, do we require a quorum or a
|
||||
> second admin's consent? For MVP, no — any admin can act alone, matching how
|
||||
> ownership transfer works today. Revisit if multi-party control becomes a
|
||||
> requirement.
|
||||
|
||||
### 6. What happens to the current `Share` model
|
||||
|
||||
The existing `backend/src/models/share.rs` is **resource-level ACL**
|
||||
(`resource_type`, `resource_id`, `permissions`) and predates encryption. It is
|
||||
incompatible with this design, which shares at the **profile** level via keys,
|
||||
not at the resource level via permissions.
|
||||
|
||||
Per issue #3, profile-level sharing covers the family/caregiver need; the
|
||||
"share one document" need is handled separately (physician PDF feature). So:
|
||||
|
||||
- The current `Share` model and `/api/shares` routes are **retained for now**
|
||||
but treated as a placeholder (issue #3). They will be either removed or
|
||||
repurposed; this ADR does not depend on them.
|
||||
- The new recipient-share records live in a **new collection** (provisional
|
||||
name `profile_shares`) keyed by `(profileId, recipientUserId)`. Storing
|
||||
wrapped DEKs, not ACLs.
|
||||
|
||||
### 7. Server surface (new endpoints, indicative)
|
||||
|
||||
- `GET /api/users/:id/public-key` → recipient's X25519 public key (plaintext).
|
||||
- `GET /api/profiles/me/dek` → the account-wrapped profile DEK (owner unlock).
|
||||
- `POST /api/profiles` → create profile: client generates profile DEK, wraps to
|
||||
account DEK, sends wrapped form. Returns `profileId`.
|
||||
- `GET /api/profiles/:id/shares` → list recipient shares for a profile.
|
||||
- `POST /api/profiles/:id/shares` → create a recipient share (wrapped DEK).
|
||||
- `DELETE /api/profiles/:id/shares/:shareId` → soft revoke.
|
||||
- `POST /api/profiles/:id/rekey` → hard revoke / rotate the profile DEK.
|
||||
|
||||
All endpoints store/return opaque blobs or public keys; none expose a DEK or a
|
||||
private key.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Satisfies all four product constraints.** Per-profile keys enable the
|
||||
divorced-parents case; hard revoke enables the revocation requirement; the
|
||||
X25519 envelope keeps the server untrusted.
|
||||
- **Profiles become first-class subjects of care** — owners, shares, pets,
|
||||
graduation all fall out naturally.
|
||||
- **Server stays a blind store.** No new backend crypto; still no `aes`/crypto
|
||||
crates in `Cargo.toml`.
|
||||
- **Composition with existing recovery.** The account DEK is still recovered
|
||||
via the existing password/recovery wrapped-DEK flow; profile DEKs are wrapped
|
||||
to the account DEK, so recovering the account recovers all profiles.
|
||||
|
||||
### Negative / costs
|
||||
|
||||
- **Adds a key tier.** Three levels instead of one. Complexity in the client
|
||||
crypto module (`crypto/keys.ts` will grow X25519 + wrap-to-recipient logic).
|
||||
- **Migration.** Today all data is under one account DEK. Splitting into
|
||||
per-profile DEKs requires reading, decrypting, re-encrypting, and re-uploading
|
||||
every document. **There is no real user data yet** (per
|
||||
`zero-knowledge-encryption.md`), so this is cheap **now** and expensive
|
||||
later — strong argument for doing the re-key before launch.
|
||||
- **Hard revocation is expensive.** Re-keying a profile is O(profile data
|
||||
size). Mitigation: soft revoke by default, hard revoke only when the owner
|
||||
explicitly requires it (e.g. graduation, suspected compromise).
|
||||
- **Recipient must have an account** (constraint 2). No magic-link sharing in
|
||||
this design — deferred to the physician-PDF feature.
|
||||
|
||||
### Security notes
|
||||
|
||||
- **Ephemeral keys per share** prevent cross-share correlation and give
|
||||
forward secrecy within a share's lifetime (re-keying rotates the DEK).
|
||||
- **ECDH + AES-GCM** is the Web Crypto idiom; X25519 keys are 32 bytes, shares
|
||||
are small.
|
||||
- **Public keys are not authenticated by the server.** The owner must trust
|
||||
that `GET /users/:id/public-key` returns the right key. Since the server is
|
||||
untrusted, this is an accepted risk: a malicious server could swap the key
|
||||
and force a re-share, but cannot decrypt. ❓ Decide whether to add a key
|
||||
fingerprint the recipient verifies out-of-band.
|
||||
|
||||
## Open questions (❓) — resolved 2026-07-18
|
||||
|
||||
1. **Key hierarchy depth.** ✅ **Decided: 3-tier** (account DEK → profile DEK →
|
||||
data). Rationale: account DEK is recovered via the existing wrapped-DEK
|
||||
recovery flow, so password change only re-wraps the account DEK (O(1)),
|
||||
not every profile DEK. Profile DEKs are re-wrapped only on profile-level
|
||||
events (share/revoke/rekey), which are rare.
|
||||
|
||||
2. **Public-key trust.** ✅ **Decided: accept key-swap as DoS-only for now.**
|
||||
A malicious server can swap a recipient's public key and force a failed
|
||||
share (DoS) but cannot decrypt. **Future enhancement (not blocking):** add
|
||||
a mechanism for out-of-band pubkey verification. Candidate approaches to
|
||||
evaluate when we get there:
|
||||
- **Key fingerprint / safety number.** Display a short hash of a user's
|
||||
pubkey that two parties can compare out-of-band (cf. Signal safety
|
||||
numbers). Cheap, requires user action.
|
||||
- **Pubkey in recovery phrase / account export.** Derive or include the
|
||||
pubkey fingerprint in something the user already verifies.
|
||||
- **Web-of-trust / TOFU.** First-seen pubkey is pinned client-side; a change
|
||||
triggers a user-visible warning.
|
||||
These are additive — none changes the core envelope design, so they can
|
||||
ship later without a migration.
|
||||
|
||||
3. **Share record schema.** ✅ **Decided: `profile_shares` collection, schema
|
||||
as proposed, permissions included.** Fields:
|
||||
`{profileId, recipientUserId, ephemeralPubKey, wrappedDek, iv, permissions,
|
||||
expiresAt, active, createdAt}`. `permissions` is a list (e.g.
|
||||
`["read"]`, `["read","write"]`, `["read","write","admin"]`); the server
|
||||
enforces write/admin server-side by checking the share record (the owner
|
||||
always implicitly has admin via the owner share). See §"Permissions and
|
||||
the admin-share question" below for the semantics of `admin`.
|
||||
|
||||
4. **Migration.** ✅ **Decided: no migration — wipe the database.** There is
|
||||
no real user data, and the re-key cost is unjustified. Phase A therefore
|
||||
includes a clean-cutover step rather than a per-document migration. (This
|
||||
also removes any legacy single-DEK documents that would otherwise need
|
||||
special-casing.)
|
||||
|
||||
5. **Existing `Share` model fate.** ✅ **Decided: remove entirely.**
|
||||
`backend/src/models/share.rs`, the `/api/shares` routes, and the
|
||||
`permissions/check` endpoint are deleted; the new `profile_shares`
|
||||
collection and `/api/profiles/:id/shares` endpoints replace them. The
|
||||
future per-resource physician-share feature will be built from scratch on
|
||||
its own model when prioritized (it's a different access pattern — one-way
|
||||
document dump, not ongoing profile access).
|
||||
|
||||
## Phasing (proposal)
|
||||
|
||||
- **Phase A — key tiers without sharing.** Introduce per-profile DEKs and the
|
||||
account X25519 keypair; migrate existing data; no sharing endpoints yet.
|
||||
Unblocks multi-profile-within-account (the parent/child single-login case).
|
||||
- **Phase B — profile sharing.** The `POST/GET/DELETE /profiles/:id/shares`
|
||||
endpoints and soft revoke.
|
||||
- **Phase C — hard revoke / re-key.** The `/profiles/:id/rekey` endpoint and
|
||||
owner-driven DEK rotation.
|
||||
- **Phase D — graduation / ownership transfer.** Owner-share swap UI + endpoint.
|
||||
|
||||
Each phase is independently shippable and independently testable.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Issue: [#3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3)
|
||||
- Predecessor ADR: [`zero-knowledge-encryption.md`](./zero-knowledge-encryption.md)
|
||||
- Current client crypto: `web/normogen-web/src/crypto/keys.ts`, `cipher.ts`
|
||||
- Profile model: `backend/src/models/profile.rs`
|
||||
- (Incompatible) current share model: `backend/src/models/share.rs`
|
||||
- Web Crypto API: X25519 via `generateKey({name:"ECDH", namedCurve:"X25519"})`,
|
||||
`deriveBits` for ECDH, `wrapKey`/`unwrapKey` or manual AES-GCM for the envelope.
|
||||
|
|
@ -1,13 +1,5 @@
|
|||
# Encryption.md Update Summary
|
||||
|
||||
> **ARCHIVED 2026-07-18.** This was a changelog note for a 2026-03-09 rewrite of
|
||||
> `docs/product/encryption.md` that itself became stale (it describes ZK
|
||||
> encryption as "planned" when it is in fact implemented). Kept here for
|
||||
> history only. The current, accurate encryption doc is
|
||||
> [`docs/product/encryption.md`](../product/encryption.md), and the canonical
|
||||
> design is [`docs/adr/zero-knowledge-encryption.md`](../adr/zero-knowledge-encryption.md).
|
||||
> See issue #4.
|
||||
|
||||
**Date**: 2026-03-09
|
||||
**File**: docs/product/encryption.md
|
||||
**Update**: Added Rust implementation examples and current status
|
||||
|
|
@ -1,16 +1,10 @@
|
|||
# Persona and Family Management - Product Definition
|
||||
|
||||
**Document Version**: 1.1
|
||||
**Date**: 2026-07-18 (reconciled with code; original draft 2026-03-09)
|
||||
**Status**: Vision doc — implementation status section corrected to match code
|
||||
**Document Version**: 1.0
|
||||
**Date**: 2026-03-09
|
||||
**Status**: Draft - For Refinement
|
||||
**Purpose**: Consolidate all current information about persona and family management features for further product refinement
|
||||
|
||||
> **How to read this doc.** Sections marked **Vision** describe the target
|
||||
> product and are still valid for discussion. Sections marked **Today (code)**
|
||||
> describe what is actually implemented and have been verified against the
|
||||
> codebase as of 2026-07-18. Where the two diverge, the gap is called out and
|
||||
> linked to the relevant Forgejo issue.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
|
@ -41,28 +35,6 @@ Normogen supports **multi-person health data management** through a persona and
|
|||
|
||||
---
|
||||
|
||||
## Current reality (as of 2026-07-18)
|
||||
|
||||
This section corrects the earlier "Current Implementation Status" section below,
|
||||
which described an earlier state. Verified against the code:
|
||||
|
||||
| Capability | Status | Notes |
|
||||
|---|---|---|
|
||||
| Single profile per user account | ✅ Done | `GET/PUT /api/profiles/me`; profile is auto-created as `profile_{user_id}` at registration. Display name is client-encrypted. |
|
||||
| Multi-profile (child/dependent) within one account | ❌ Not built | `ProfileRepository::find_by_user_id` returns a single profile; no create/list/switch endpoints. `profileId` can be tagged onto medications but there is no backing entity beyond the owner's. |
|
||||
| Family groups | ❌ Not built | `Family` model exists but has **no handler and no routes**. JWT carries `family_id` but nothing populates or checks it. |
|
||||
| Cross-user sharing (spouse, caregiver) | ❌ **Blocked by design** | `/api/shares` routes exist but cannot grant decryption access — all data is encrypted under the owner's DEK and there is no key-distribution mechanism. See **[issue #3](https://gitea.soliverez.com.ar/alvaro/normogen/issues/3)**. |
|
||||
| Caregiver roles / permissions | ❌ Not built | `permissions` field exists on Profile and in JWT claims but is not enforced. |
|
||||
| Zero-knowledge encryption of health data | ✅ Done | Client-side Web Crypto, wrapped-DEK recovery. See [`encryption.md`](encryption.md). |
|
||||
|
||||
**Implication for the family/caregiver personas below:** the single biggest
|
||||
open question is architectural — *how does sharing work under zero-knowledge?*
|
||||
That must be decided before family/caregiver features can be implemented. Until
|
||||
then, the realistic near-term scope is **multi-profile within a single account**
|
||||
(one login managing several people's data, no cross-account sharing).
|
||||
|
||||
---
|
||||
|
||||
## User Personas
|
||||
|
||||
### Primary: Privacy-Conscious Individual
|
||||
|
|
@ -195,12 +167,7 @@ User Account: jane.doe@example.com (Jane)
|
|||
|
||||
---
|
||||
|
||||
## Current Implementation Status (superseded — see "Current reality" above)
|
||||
|
||||
> The detail below was written 2026-03-09 against an earlier state and
|
||||
> overstates what existed then. It is retained for history. For the accurate
|
||||
> current picture, read the **Current reality** section near the top of this
|
||||
> document, and [`encryption.md`](encryption.md) for encryption.
|
||||
## Current Implementation Status
|
||||
|
||||
### ✅ Implemented (Backend)
|
||||
|
||||
|
|
@ -628,23 +595,16 @@ if profile.role == "child" || profile.age < 18 {
|
|||
|
||||
### Encryption
|
||||
|
||||
> **Correction (2026-07-18):** the bullets below described a planned
|
||||
> server-side scheme that was never built. The shipped system is
|
||||
> **client-side** zero-knowledge via the browser's Web Crypto API with a
|
||||
> wrapped-DEK model. The server has no crypto code at all — it is a blind
|
||||
> store. Profile/medication/appointment data is encrypted in the browser
|
||||
> before upload and the server cannot read any of it. Full details:
|
||||
> [`encryption.md`](encryption.md) and
|
||||
> [`adr/zero-knowledge-encryption.md`](../adr/zero-knowledge-encryption.md).
|
||||
**Zero-Knowledge Encryption**:
|
||||
- Profile names are encrypted (AES-256-GCM)
|
||||
- Family names are encrypted
|
||||
- Only users with correct password can decrypt
|
||||
- Server cannot see profile names
|
||||
|
||||
**What's actually encrypted (client-side, today):**
|
||||
- Medication data blob (name, dosage, frequency, route, notes, …)
|
||||
- Appointment data blob (title, provider, date/time, …)
|
||||
- Profile display name
|
||||
|
||||
**What stays plaintext (queryable by the server):**
|
||||
- IDs (`profileId`, `medicationId`, `userId`, `appointmentId`)
|
||||
- Medication `active` flag, appointment `status`, `doseSchedule`, timestamps
|
||||
**Encrypted Fields**:
|
||||
- `profile.name` - Person's name
|
||||
- `family.name` - Family group name
|
||||
- Medication data (when implemented)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -24,7 +24,7 @@ import EditIcon from '@mui/icons-material/Edit';
|
|||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { format } from 'date-fns';
|
||||
import { useAppointmentStore, useProfileStore } from '../../store/useStore';
|
||||
import { useAppointmentStore, useAuthStore } from '../../store/useStore';
|
||||
import type { Appointment } from '../../types/api';
|
||||
|
||||
const APPT_TYPES = ['in-person', 'telehealth', 'lab', 'test', 'other'];
|
||||
|
|
@ -74,7 +74,7 @@ export const AppointmentsManager: FC = () => {
|
|||
deleteAppointment,
|
||||
clearError,
|
||||
} = useAppointmentStore();
|
||||
const activeProfileId = useProfileStore((s) => s.activeProfileId);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createForm, setCreateForm] = useState<ApptFormData>(emptyCreate);
|
||||
|
|
@ -84,13 +84,13 @@ export const AppointmentsManager: FC = () => {
|
|||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeProfileId) loadAppointments();
|
||||
}, [loadAppointments, activeProfileId]);
|
||||
loadAppointments();
|
||||
}, [loadAppointments]);
|
||||
|
||||
const openCreate = () => {
|
||||
setCreateForm({
|
||||
...emptyCreate,
|
||||
profile_id: activeProfileId ?? 'default',
|
||||
profile_id: `profile_${user?.user_id ?? 'default'}`,
|
||||
});
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -38,8 +38,6 @@ describe('MedicationManager', () => {
|
|||
resetMockStore();
|
||||
setMockStore({
|
||||
useAuthStore: { user: { user_id: 'u1', username: 'tester' }, profile: {} },
|
||||
// The load effect is gated on activeProfileId (Phase A2); provide one.
|
||||
useProfileStore: { activeProfileId: 'profile_u1' },
|
||||
});
|
||||
baseActions.loadMedications.mockClear();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import {
|
|||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { useMedicationStore, useProfileStore } from '../../store/useStore';
|
||||
import { useMedicationStore, useAuthStore } from '../../store/useStore';
|
||||
import type { Medication } from '../../types/api';
|
||||
import { DoseLogger } from './DoseLogger';
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ export const MedicationManager: FC = () => {
|
|||
deleteMedication,
|
||||
clearError,
|
||||
} = useMedicationStore();
|
||||
const activeProfileId = useProfileStore((s) => s.activeProfileId);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createForm, setCreateForm] = useState<MedFormData>(emptyCreate);
|
||||
|
|
@ -69,16 +69,17 @@ export const MedicationManager: FC = () => {
|
|||
const [deleteTarget, setDeleteTarget] = useState<Medication | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Reload medications when the active profile changes — each profile's data
|
||||
// is encrypted under its own DEK and filtered server-side by profile_id.
|
||||
useEffect(() => {
|
||||
if (activeProfileId) loadMedications();
|
||||
}, [loadMedications, activeProfileId]);
|
||||
loadMedications();
|
||||
}, [loadMedications]);
|
||||
|
||||
const openCreate = () => {
|
||||
// profile_id is deterministic: profile_<user_id>. The backend auto-creates
|
||||
// this profile on register, so the id always resolves to a real profile.
|
||||
const profileId = user?.profile_id ?? `profile_${user?.user_id ?? 'default'}`;
|
||||
setCreateForm({
|
||||
...emptyCreate,
|
||||
profile_id: activeProfileId ?? 'default',
|
||||
profile_id: profileId,
|
||||
});
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,62 +7,34 @@ import {
|
|||
CircularProgress,
|
||||
Alert,
|
||||
Chip,
|
||||
MenuItem,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { useProfileStore, useAuthStore } from '../../store/useStore';
|
||||
import { ProfileSharing } from './ProfileSharing';
|
||||
|
||||
export const ProfileEditor: FC = () => {
|
||||
const {
|
||||
profiles,
|
||||
activeProfileId,
|
||||
isLoading,
|
||||
error,
|
||||
loadProfiles,
|
||||
updateProfile,
|
||||
createProfile,
|
||||
deleteProfile,
|
||||
clearError,
|
||||
} = useProfileStore();
|
||||
const { profile, isLoading, error, loadProfile, updateName, clearError } =
|
||||
useProfileStore();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const active = profiles.find((p) => p.profile_id === activeProfileId) ?? null;
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [kind, setKind] = useState('human');
|
||||
const [relationship, setRelationship] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Create-profile dialog state
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newKind, setNewKind] = useState('human');
|
||||
const [newRel, setNewRel] = useState('');
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
}, [loadProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
loadProfiles();
|
||||
}, [loadProfiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
setName(active.name);
|
||||
setKind(active.kind || 'human');
|
||||
setRelationship(active.relationship || '');
|
||||
}
|
||||
}, [active]);
|
||||
if (profile?.name) setName(profile.name);
|
||||
}, [profile]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!active) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateProfile(active.profile_id, { name, kind, relationship });
|
||||
await updateName(name);
|
||||
setEditing(false);
|
||||
} catch {
|
||||
/* error surfaced via store */
|
||||
|
|
@ -71,43 +43,11 @@ export const ProfileEditor: FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await createProfile({ name: newName, kind: newKind, relationship: newRel });
|
||||
setCreating(false);
|
||||
setNewName('');
|
||||
setNewKind('human');
|
||||
setNewRel('');
|
||||
} catch {
|
||||
/* error surfaced via store */
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!active) return;
|
||||
if (profiles.length <= 1) {
|
||||
alert('You must have at least one profile. Create another before deleting this one.');
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Delete the "${active.name}" profile? Its encrypted data cannot be recovered.`)) return;
|
||||
try {
|
||||
await deleteProfile(active.profile_id);
|
||||
} catch {
|
||||
/* error surfaced via store */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
|
||||
<Typography variant="h6">Profiles</Typography>
|
||||
<Button startIcon={<AddIcon />} onClick={() => setCreating(true)}>
|
||||
Add profile
|
||||
</Button>
|
||||
</Stack>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Profile
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={clearError}>
|
||||
|
|
@ -115,11 +55,11 @@ export const ProfileEditor: FC = () => {
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
{isLoading && profiles.length === 0 ? (
|
||||
{isLoading && !profile ? (
|
||||
<Box display="flex" justifyContent="center" py={4}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : active ? (
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
|
|
@ -144,154 +84,39 @@ export const ProfileEditor: FC = () => {
|
|||
>
|
||||
{saving ? <CircularProgress size={24} /> : 'Save'}
|
||||
</Button>
|
||||
<Button onClick={() => { setEditing(false); setName(active.name); }}>
|
||||
<Button onClick={() => { setEditing(false); setName(profile?.name ?? ''); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Typography variant="h6">{active.name || user?.username || '—'}</Typography>
|
||||
{active.is_shared ? (
|
||||
<Chip size="small" label="shared with you" color="info" variant="outlined" />
|
||||
) : (
|
||||
<Button size="small" onClick={() => setEditing(true)}>Edit</Button>
|
||||
)}
|
||||
<Typography variant="h6">{profile?.name ?? user?.username ?? '—'}</Typography>
|
||||
<Button size="small" onClick={() => setEditing(true)}>Edit</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{editing && (
|
||||
<>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Kind</Typography>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
fullWidth
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value)}
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
<MenuItem value="human">Human</MenuItem>
|
||||
<MenuItem value="pet">Pet</MenuItem>
|
||||
</TextField>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Relationship
|
||||
</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
value={relationship}
|
||||
onChange={(e) => setRelationship(e.target.value)}
|
||||
placeholder="self, child, spouse, parent, pet, ..."
|
||||
sx={{ mt: 0.5 }}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Account
|
||||
</Typography>
|
||||
<Typography variant="body2">{user?.email}</Typography>
|
||||
</Box>
|
||||
|
||||
{!editing && (
|
||||
<>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Kind / relationship</Typography>
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<Chip size="small" label={active.kind || 'human'} variant="outlined" sx={{ mr: 1 }} />
|
||||
{active.relationship && (
|
||||
<Chip size="small" label={active.relationship} variant="outlined" />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{active.is_shared ? (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Owner</Typography>
|
||||
<Typography variant="body2">{active.owner_account_id}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
You have read-only access to this shared profile.
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Account</Typography>
|
||||
<Typography variant="body2">{user?.email}</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Profile ID: {active.profile_id}
|
||||
</Typography>
|
||||
<Box>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete profile
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Owner-only sharing UI. */}
|
||||
{!editing && !active.is_shared && (
|
||||
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider' }}>
|
||||
<ProfileSharing profileId={active.profile_id} />
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Role
|
||||
</Typography>
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<Chip size="small" label={profile?.role ?? 'patient'} variant="outlined" />
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Alert severity="info">
|
||||
No profiles yet. Create one to start tracking health data.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{creating && (
|
||||
<Card sx={{ mt: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" sx={{ mb: 2 }}>New profile</Typography>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Display name"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
label="Kind"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={newKind}
|
||||
onChange={(e) => setNewKind(e.target.value)}
|
||||
>
|
||||
<MenuItem value="human">Human</MenuItem>
|
||||
<MenuItem value="pet">Pet</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Relationship"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={newRel}
|
||||
onChange={(e) => setNewRel(e.target.value)}
|
||||
placeholder="child, spouse, parent, pet, ..."
|
||||
/>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={handleCreate}
|
||||
disabled={saving || !newName.trim()}
|
||||
>
|
||||
{saving ? <CircularProgress size={24} /> : 'Create'}
|
||||
</Button>
|
||||
<Button onClick={() => setCreating(false)}>Cancel</Button>
|
||||
</Stack>
|
||||
{profile?.profile_id && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Profile ID: {profile.profile_id}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -1,189 +0,0 @@
|
|||
import { useEffect, useState, type FC } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import PersonAddIcon from '@mui/icons-material/PersonAdd';
|
||||
import PersonRemoveIcon from '@mui/icons-material/PersonRemove';
|
||||
import KeyIcon from '@mui/icons-material/Key';
|
||||
import { useProfileStore } from '../../store/useStore';
|
||||
|
||||
/** Owner-side sharing UI for a single owned profile: lists the recipients the
|
||||
* profile is shared with (with revoke buttons) and an "add recipient by
|
||||
* email" field. Shown only for profiles the current user owns. Recipients
|
||||
* get the profile via /profiles/shared-with-me + the ECDH-wrapped DEK. */
|
||||
export const ProfileSharing: FC<{ profileId: string }> = ({ profileId }) => {
|
||||
const {
|
||||
profileShares,
|
||||
loadProfileShares,
|
||||
shareProfile,
|
||||
revokeShare,
|
||||
rekeyProfile,
|
||||
error,
|
||||
clearError,
|
||||
} = useProfileStore();
|
||||
|
||||
const [recipientEmail, setRecipientEmail] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [localError, setLocalError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadProfileShares(profileId);
|
||||
}, [profileId, loadProfileShares]);
|
||||
|
||||
const shares = profileShares[profileId] ?? [];
|
||||
|
||||
const handleAdd = async () => {
|
||||
setBusy(true);
|
||||
setLocalError('');
|
||||
try {
|
||||
await shareProfile(profileId, recipientEmail.trim());
|
||||
setRecipientEmail('');
|
||||
} catch (e: any) {
|
||||
setLocalError(e?.message || 'Failed to share');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async (recipientUserId: string) => {
|
||||
if (!confirm('Revoke this share? The recipient will lose access immediately.')) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await revokeShare(profileId, recipientUserId);
|
||||
} catch {
|
||||
/* surfaced via store error */
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHardRevoke = async () => {
|
||||
// Hard revoke = rotate the profile DEK. All currently-listed recipients
|
||||
// are KEPT (re-wrapped to the new DEK); the owner removes specific
|
||||
// recipients via the per-row revoke button above first if they want them
|
||||
// gone at the key level. Expensive (re-encrypts all the profile's data),
|
||||
// but resume-safe — partial runs can be retried.
|
||||
const ok = confirm(
|
||||
'Rotate this profile\'s encryption key?\n\n' +
|
||||
'This re-encrypts ALL the profile\'s data (medications, appointments, ' +
|
||||
'health stats) under a new key — it may take a moment. All currently-' +
|
||||
'listed recipients keep access (re-wrapped to the new key). Anyone who ' +
|
||||
'previously had access and was removed, or any cached copy of the old ' +
|
||||
'key, will stop working.\n\nUse this if you suspect a key was compromised.',
|
||||
);
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
setLocalError('');
|
||||
try {
|
||||
const keepIds = shares.map((s) => s.recipient_user_id);
|
||||
await rekeyProfile(profileId, keepIds);
|
||||
} catch (e: any) {
|
||||
setLocalError(
|
||||
(e?.message || 'Re-key failed') +
|
||||
' — you can retry; rows already re-encrypted are skipped.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>
|
||||
Shared with
|
||||
</Typography>
|
||||
|
||||
{(error || localError) && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ mb: 2 }}
|
||||
onClose={() => {
|
||||
setLocalError('');
|
||||
clearError();
|
||||
}}
|
||||
>
|
||||
{localError || error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{shares.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Not shared with anyone.
|
||||
</Typography>
|
||||
) : (
|
||||
<List dense sx={{ mb: 2, bgcolor: 'background.paper', borderRadius: 1 }}>
|
||||
{shares.map((s) => (
|
||||
<ListItem key={s.recipient_user_id}>
|
||||
<ListItemText
|
||||
primary={s.recipient_email || s.recipient_user_id}
|
||||
secondary={s.permissions.join(', ')}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label="revoke"
|
||||
disabled={busy}
|
||||
onClick={() => handleRevoke(s.recipient_user_id)}
|
||||
>
|
||||
<PersonRemoveIcon />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
type="email"
|
||||
placeholder="recipient@example.com"
|
||||
value={recipientEmail}
|
||||
onChange={(e) => setRecipientEmail(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PersonAddIcon />}
|
||||
disabled={busy || !recipientEmail.trim()}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
{busy ? <CircularProgress size={24} /> : 'Share'}
|
||||
</Button>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
The recipient must have a Normogen account. They'll see this profile in their switcher.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 3, pt: 2, borderTop: 1, borderColor: 'divider' }}>
|
||||
<Button
|
||||
size="small"
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
startIcon={<KeyIcon />}
|
||||
disabled={busy}
|
||||
onClick={handleHardRevoke}
|
||||
>
|
||||
{busy ? <CircularProgress size={20} /> : 'Rotate encryption key (hard revoke)'}
|
||||
</Button>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
Generates a new key and re-encrypts this profile's data. Use if a key
|
||||
may have been compromised. Resume-safe — you can retry on failure.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileSharing;
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import { useEffect, type FC } from 'react';
|
||||
import { Box, Chip, CircularProgress, MenuItem, Select, Typography } from '@mui/material';
|
||||
import { useProfileStore } from '../../store/useStore';
|
||||
|
||||
/** A compact dropdown for switching the active profile (the subject of care
|
||||
* whose data the dashboard shows). Sits in the AppBar. Triggers `loadProfiles`
|
||||
* + `loadSharedWithMe` on mount so owned AND shared profiles are ready. */
|
||||
export const ProfileSwitcher: FC = () => {
|
||||
const { profiles, activeProfileId, isLoading, loadProfiles, loadSharedWithMe, setActiveProfile } =
|
||||
useProfileStore();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await loadProfiles();
|
||||
// Fetch profiles shared TO me after owned profiles are loaded (the
|
||||
// identity private key must be unwrapped first; loadSharedWithMe is a
|
||||
// no-op if it isn't available yet).
|
||||
await loadSharedWithMe();
|
||||
})();
|
||||
}, [loadProfiles, loadSharedWithMe]);
|
||||
|
||||
if (profiles.length === 0) {
|
||||
return isLoading ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CircularProgress size={18} sx={{ color: 'common.white' }} />
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ opacity: 0.7, mr: 2 }}>
|
||||
No profile
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
size="small"
|
||||
value={activeProfileId ?? ''}
|
||||
onChange={(e) => setActiveProfile(e.target.value)}
|
||||
sx={{
|
||||
mr: 2,
|
||||
color: 'common.white',
|
||||
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.5)' },
|
||||
'.MuiSvgIcon-root': { color: 'common.white' },
|
||||
}}
|
||||
renderValue={(value) => {
|
||||
const p = profiles.find((x) => x.profile_id === value);
|
||||
return p ? (p.name || p.relationship || p.profile_id) : '';
|
||||
}}
|
||||
>
|
||||
{profiles.map((p) => (
|
||||
<MenuItem key={p.profile_id} value={p.profile_id}>
|
||||
<span>{p.name || p.relationship || p.profile_id}</span>
|
||||
{p.is_shared && (
|
||||
<Chip
|
||||
size="small"
|
||||
label="shared"
|
||||
sx={{ ml: 1, height: 18, fontSize: '0.7rem' }}
|
||||
/>
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileSwitcher;
|
||||
|
|
@ -6,43 +6,11 @@ import {
|
|||
rewrapDek,
|
||||
encryptJson,
|
||||
decryptJson,
|
||||
encrypt,
|
||||
decrypt,
|
||||
setEncKey,
|
||||
clearEncKey,
|
||||
hasEncKey,
|
||||
generateIdentityKeyPair,
|
||||
wrapIdentityPrivateKey,
|
||||
unwrapIdentityPrivateKey,
|
||||
setIdentityPrivate,
|
||||
clearIdentityPrivate,
|
||||
hasIdentityPrivate,
|
||||
generateProfileDek,
|
||||
wrapProfileDek,
|
||||
unwrapProfileDek,
|
||||
wrapProfileDekToRecipient,
|
||||
unwrapProfileDekFromShare,
|
||||
setProfileDek,
|
||||
getProfileDek,
|
||||
clearProfileDeks,
|
||||
} from './index';
|
||||
|
||||
/** X25519 in Web Crypto is a recent addition (Node 16+, modern browsers). Some
|
||||
* test runtimes (older jsdom/Node) may not implement it; detect once and skip
|
||||
* the identity-keypair tests rather than fail spuriously. */
|
||||
async function x25519Supported(): Promise<boolean> {
|
||||
try {
|
||||
await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'X25519' },
|
||||
true,
|
||||
['deriveBits'],
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe('zero-knowledge crypto lifecycle', () => {
|
||||
const password = 'my-secret-password';
|
||||
const recoveryPhrase = 'my-recovery-phrase';
|
||||
|
|
@ -131,205 +99,3 @@ describe('zero-knowledge crypto lifecycle', () => {
|
|||
await expect(decryptJson(encrypted, setupB.dek)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('account X25519 identity keypair (Phase A1)', () => {
|
||||
const password = 'my-secret-password';
|
||||
|
||||
it('full lifecycle: generate → wrap → unlock DEK → unwrap → deriveBits', async () => {
|
||||
if (!(await x25519Supported())) {
|
||||
console.log('[crypto] skipped (X25519 unsupported in this runtime)');
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Register: set up the account DEK and generate the identity keypair.
|
||||
const setup = await setupEncryption(password);
|
||||
const { publicKey, privateKey } = await generateIdentityKeyPair();
|
||||
expect(publicKey).not.toBe('');
|
||||
setIdentityPrivate(privateKey);
|
||||
expect(hasIdentityPrivate()).toBe(true);
|
||||
|
||||
// 2. Wrap the private key under the account DEK and "store" it.
|
||||
const wrapped = await wrapIdentityPrivateKey(privateKey, setup.dek);
|
||||
expect(wrapped.data).not.toBe('');
|
||||
|
||||
// 3. Simulate page reload: both keys are lost from memory.
|
||||
clearIdentityPrivate();
|
||||
expect(hasIdentityPrivate()).toBe(false);
|
||||
|
||||
// 4. Unlock the DEK with the password (unlock screen flow).
|
||||
const { dek } = await unlockWithPassword(password, setup.passwordWrappedDek);
|
||||
|
||||
// 5. Unwrap the identity private key from the stored wrapped form.
|
||||
const recovered = await unwrapIdentityPrivateKey(wrapped, dek);
|
||||
setIdentityPrivate(recovered);
|
||||
expect(hasIdentityPrivate()).toBe(true);
|
||||
|
||||
// 6. The recovered private key is a USABLE X25519 key: it can derive a
|
||||
// shared secret against the original public key. Import the public key
|
||||
// and ECDH-derive — this proves the round-trip preserved a working key.
|
||||
const pubBytes = Uint8Array.from(atob(publicKey), (c) => c.charCodeAt(0));
|
||||
const pubKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
pubBytes as BufferSource,
|
||||
{ name: 'ECDH', namedCurve: 'X25519' },
|
||||
false,
|
||||
[],
|
||||
);
|
||||
const sharedSecret = await crypto.subtle.deriveBits(
|
||||
{ name: 'ECDH', public: pubKey },
|
||||
recovered,
|
||||
256,
|
||||
);
|
||||
expect(sharedSecret.byteLength).toBe(32);
|
||||
|
||||
clearIdentityPrivate();
|
||||
});
|
||||
|
||||
it('a wrapped private key cannot be unwrapped with the wrong DEK', async () => {
|
||||
if (!(await x25519Supported())) {
|
||||
console.log('[crypto] skipped (X25519 unsupported in this runtime)');
|
||||
return;
|
||||
}
|
||||
// DEK for account A; a different DEK for account B.
|
||||
const setupA = await setupEncryption('user-a-password');
|
||||
const setupB = await setupEncryption('user-b-password');
|
||||
const { privateKey } = await generateIdentityKeyPair();
|
||||
const wrapped = await wrapIdentityPrivateKey(privateKey, setupA.dek);
|
||||
|
||||
// User B's DEK cannot unwrap user A's identity private key.
|
||||
await expect(unwrapIdentityPrivateKey(wrapped, setupB.dek)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('two identity keypairs derive different shared secrets with the same peer', async () => {
|
||||
if (!(await x25519Supported())) {
|
||||
console.log('[crypto] skipped (X25519 unsupported in this runtime)');
|
||||
return;
|
||||
}
|
||||
// Sanity: distinct keypairs must produce distinct ECDH outputs.
|
||||
const peer = await generateIdentityKeyPair();
|
||||
const peerPubBytes = Uint8Array.from(atob(peer.publicKey), (c) => c.charCodeAt(0));
|
||||
const peerPub = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
peerPubBytes as BufferSource,
|
||||
{ name: 'ECDH', namedCurve: 'X25519' },
|
||||
false,
|
||||
[],
|
||||
);
|
||||
|
||||
const a = await generateIdentityKeyPair();
|
||||
const b = await generateIdentityKeyPair();
|
||||
const secretA = await crypto.subtle.deriveBits(
|
||||
{ name: 'ECDH', public: peerPub },
|
||||
a.privateKey,
|
||||
256,
|
||||
);
|
||||
const secretB = await crypto.subtle.deriveBits(
|
||||
{ name: 'ECDH', public: peerPub },
|
||||
b.privateKey,
|
||||
256,
|
||||
);
|
||||
|
||||
const aHex = Array.from(new Uint8Array(secretA)).map((x) => x.toString(16)).join('');
|
||||
const bHex = Array.from(new Uint8Array(secretB)).map((x) => x.toString(16)).join('');
|
||||
expect(aHex).not.toBe(bHex);
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-profile DEK isolation (Phase A2)', () => {
|
||||
const password = 'my-secret-password';
|
||||
|
||||
it('two profiles get distinct DEKs, each wrapped under the account DEK', async () => {
|
||||
// One account DEK (unlocked from the password).
|
||||
const setup = await setupEncryption(password);
|
||||
const accountDek = setup.dek;
|
||||
|
||||
// Two profiles, each with its own random DEK.
|
||||
const childDek = await generateProfileDek();
|
||||
const petDek = await generateProfileDek();
|
||||
|
||||
// Each profile DEK is wrapped under the account DEK for storage.
|
||||
const childWrapped = await wrapProfileDek(childDek, accountDek);
|
||||
const petWrapped = await wrapProfileDek(petDek, accountDek);
|
||||
|
||||
// Both wrapped blobs decrypt cleanly under the account DEK.
|
||||
const childUnwrapped = await unwrapProfileDek(childWrapped, accountDek);
|
||||
const petUnwrapped = await unwrapProfileDek(petWrapped, accountDek);
|
||||
expect(childUnwrapped).toBeDefined();
|
||||
expect(petUnwrapped).toBeDefined();
|
||||
|
||||
// Data encrypted under the child profile's DEK cannot be decrypted by the
|
||||
// pet profile's DEK (and vice versa) — this is the per-profile isolation
|
||||
// guarantee that makes the parent/child and pet cases safe.
|
||||
const childData = await encrypt('child-medication', childDek);
|
||||
await expect(decrypt(childData, petDek)).rejects.toThrow();
|
||||
const petData = await encrypt('pet-weight', petDek);
|
||||
await expect(decrypt(petData, childDek)).rejects.toThrow();
|
||||
|
||||
// ...but each decrypts with its own key.
|
||||
expect(await decrypt(childData, childDek)).toBe('child-medication');
|
||||
expect(await decrypt(petData, petDek)).toBe('pet-weight');
|
||||
});
|
||||
|
||||
it('the in-memory profile-DEK store maps profile ids to DEKs', async () => {
|
||||
const childDek = await generateProfileDek();
|
||||
const petDek = await generateProfileDek();
|
||||
setProfileDek('profile_child', childDek);
|
||||
setProfileDek('profile_pet', petDek);
|
||||
// Each id resolves to the DEK that was stored under it; unknown ids resolve to null.
|
||||
expect(getProfileDek('profile_child')).toBe(childDek);
|
||||
expect(getProfileDek('profile_pet')).toBe(petDek);
|
||||
expect(getProfileDek('profile_unknown')).toBeNull();
|
||||
// Clearing drops everything.
|
||||
clearProfileDeks();
|
||||
expect(getProfileDek('profile_child')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('profile sharing: X25519 envelope (Phase B)', () => {
|
||||
it('owner wraps a profile DEK to a recipient, who unwraps it', async () => {
|
||||
if (!(await x25519Supported())) {
|
||||
console.log('[crypto] skipped (X25519 unsupported in this runtime)');
|
||||
return;
|
||||
}
|
||||
// Two accounts, each with their own identity keypair.
|
||||
const owner = await generateIdentityKeyPair();
|
||||
const recipient = await generateIdentityKeyPair();
|
||||
|
||||
// A profile DEK the owner created (would normally be wrapped under the
|
||||
// owner's account DEK too — irrelevant for this test).
|
||||
const profileDek = await generateProfileDek();
|
||||
|
||||
// Owner wraps the profile DEK to the recipient's public key. Internally
|
||||
// this derives a fresh ephemeral keypair + ECDH(recipient pub, ephemeral
|
||||
// priv) and AES-GCM-wraps the DEK under the shared secret.
|
||||
const envelope = await wrapProfileDekToRecipient(
|
||||
profileDek,
|
||||
recipient.publicKey,
|
||||
owner.privateKey, // not actually used by wrap (only recipient pub matters)
|
||||
);
|
||||
expect(envelope.ephemeralPublicKey).not.toBe('');
|
||||
expect(envelope.wrappedProfileDek.data).not.toBe('');
|
||||
|
||||
// Recipient unwraps with their private key + the envelope's ephemeral pub.
|
||||
const recoveredDek = await unwrapProfileDekFromShare(
|
||||
envelope.wrappedProfileDek,
|
||||
envelope.ephemeralPublicKey,
|
||||
recipient.privateKey,
|
||||
);
|
||||
expect(recoveredDek).toBeDefined();
|
||||
|
||||
// The recovered DEK encrypts/decrypts the same data the original did.
|
||||
const data = await encrypt('shared-medication', profileDek);
|
||||
expect(await decrypt(data, recoveredDek)).toBe('shared-medication');
|
||||
|
||||
// A *different* recipient's private key cannot unwrap the share.
|
||||
const stranger = await generateIdentityKeyPair();
|
||||
await expect(
|
||||
unwrapProfileDekFromShare(
|
||||
envelope.wrappedProfileDek,
|
||||
envelope.ephemeralPublicKey,
|
||||
stranger.privateKey,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,29 +8,10 @@ export {
|
|||
setupEncryption,
|
||||
unlockWithPassword,
|
||||
unlockWithRecovery,
|
||||
generateIdentityKeyPair,
|
||||
wrapIdentityPrivateKey,
|
||||
unwrapIdentityPrivateKey,
|
||||
generateProfileDek,
|
||||
wrapProfileDek,
|
||||
unwrapProfileDek,
|
||||
wrapProfileDekToRecipient,
|
||||
unwrapProfileDekFromShare,
|
||||
type SharedProfileDekEnvelope,
|
||||
setEncKey,
|
||||
getEncKey,
|
||||
clearEncKey,
|
||||
hasEncKey,
|
||||
setIdentityPrivate,
|
||||
getIdentityPrivate,
|
||||
clearIdentityPrivate,
|
||||
hasIdentityPrivate,
|
||||
setProfileDek,
|
||||
getProfileDek,
|
||||
setActiveProfileId,
|
||||
getActiveProfileId,
|
||||
getActiveProfileDek,
|
||||
clearProfileDeks,
|
||||
AUTH_SALT,
|
||||
PASSWORD_KEK_SALT,
|
||||
RECOVERY_KEK_SALT,
|
||||
|
|
|
|||
|
|
@ -114,130 +114,6 @@ export async function rewrapDek(
|
|||
return wrapDek(dek, newKek);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-profile DEK wrap/unwrap (Phase A2).
|
||||
//
|
||||
// Each profile owns a random AES-256-GCM DEK that encrypts all of that
|
||||
// profile's data. The profile DEK is wrapped under the *account* DEK (not a
|
||||
// KEK derived from a secret) and stored on the server. At login/unlock the
|
||||
// client unwraps each profile DEK using the account DEK. See
|
||||
// docs/adr/multi-person-sharing.md §1.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Generate a fresh random profile DEK (a random AES-256-GCM key). */
|
||||
export async function generateProfileDek(): Promise<CryptoKey> {
|
||||
return generateDek();
|
||||
}
|
||||
|
||||
/** Wrap a profile DEK under the account DEK. The account DEK is a CryptoKey
|
||||
* usable for AES-GCM encrypt/decrypt, so it serves directly as the wrap key. */
|
||||
export async function wrapProfileDek(
|
||||
profileDek: CryptoKey,
|
||||
accountDek: CryptoKey,
|
||||
): Promise<CipherPayload> {
|
||||
return wrapDek(profileDek, accountDek);
|
||||
}
|
||||
|
||||
/** Unwrap a profile DEK from its account-wrapped form. Inverse of
|
||||
* wrapProfileDek. Throws on tamper / wrong account DEK. */
|
||||
export async function unwrapProfileDek(
|
||||
payload: CipherPayload,
|
||||
accountDek: CryptoKey,
|
||||
): Promise<CryptoKey> {
|
||||
return unwrapDek(payload, accountDek);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile sharing: X25519 envelope (Phase B).
|
||||
//
|
||||
// To share a profile, the owner generates a fresh ephemeral X25519 keypair,
|
||||
// ECDH-derives a shared secret from (ephemeral private, recipient public),
|
||||
// uses it as an AES-GCM wrapping key, and wraps the profile DEK. The server
|
||||
// stores the wrapped DEK + the ephemeral public key (plaintext). The recipient
|
||||
// ECDH-derives the same shared secret from (their private, ephemeral public)
|
||||
// and unwraps the profile DEK. See docs/adr/multi-person-sharing.md §3.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Shape returned by the owner-side share wrapping. */
|
||||
export interface SharedProfileDekEnvelope {
|
||||
/** base64 ephemeral X25519 public key (send to the server plaintext). */
|
||||
ephemeralPublicKey: string;
|
||||
/** Profile DEK wrapped under the ECDH-derived key. */
|
||||
wrappedProfileDek: CipherPayload;
|
||||
}
|
||||
|
||||
/** Import a base64-raw X25519 public key for ECDH. */
|
||||
async function importX25519Public(publicKeyB64: string): Promise<CryptoKey> {
|
||||
const raw = Uint8Array.from(atob(publicKeyB64), (c) => c.charCodeAt(0));
|
||||
return crypto.subtle.importKey(
|
||||
'raw',
|
||||
raw as BufferSource,
|
||||
{ name: 'ECDH', namedCurve: 'X25519' },
|
||||
true,
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
/** Owner: wrap a profile DEK to a recipient's identity public key. Generates
|
||||
* a fresh ephemeral keypair per share. Requires the owner's identity private
|
||||
* key (in-memory via getIdentityPrivate()). */
|
||||
export async function wrapProfileDekToRecipient(
|
||||
profileDek: CryptoKey,
|
||||
recipientPublicKeyB64: string,
|
||||
myIdentityPrivate: CryptoKey,
|
||||
): Promise<SharedProfileDekEnvelope> {
|
||||
// Fresh ephemeral keypair for this share (forward secrecy within its lifetime).
|
||||
const ephemeral = (await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'X25519' },
|
||||
true,
|
||||
['deriveBits'],
|
||||
)) as CryptoKeyPair;
|
||||
const recipientPub = await importX25519Public(recipientPublicKeyB64);
|
||||
// ECDH(ephemeral private, recipient public) -> shared secret -> AES-GCM key.
|
||||
const sharedBits = await crypto.subtle.deriveBits(
|
||||
{ name: 'ECDH', public: recipientPub },
|
||||
ephemeral.privateKey,
|
||||
256,
|
||||
);
|
||||
const wrapKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
sharedBits,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
const wrappedProfileDek = await wrapDek(profileDek, wrapKey);
|
||||
const rawEphemeralPub = await crypto.subtle.exportKey('raw', ephemeral.publicKey);
|
||||
return {
|
||||
ephemeralPublicKey: base64(new Uint8Array(rawEphemeralPub)),
|
||||
wrappedProfileDek,
|
||||
};
|
||||
}
|
||||
|
||||
/** Recipient: unwrap a profile DEK from a share using the recipient's identity
|
||||
* private key + the share's ephemeral public key. Inverse of
|
||||
* wrapProfileDekToRecipient. Throws on tamper / wrong keys. */
|
||||
export async function unwrapProfileDekFromShare(
|
||||
wrapped: CipherPayload,
|
||||
ephemeralPublicKeyB64: string,
|
||||
myIdentityPrivate: CryptoKey,
|
||||
): Promise<CryptoKey> {
|
||||
const ephemeralPub = await importX25519Public(ephemeralPublicKeyB64);
|
||||
const sharedBits = await crypto.subtle.deriveBits(
|
||||
{ name: 'ECDH', public: ephemeralPub },
|
||||
myIdentityPrivate,
|
||||
256,
|
||||
);
|
||||
const wrapKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
sharedBits,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
return unwrapDek(wrapped, wrapKey);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// High-level flows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -309,62 +185,6 @@ export async function unlockWithRecovery(
|
|||
return unwrapDek(recoveryWrappedDek, recoveryKek);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Account X25519 identity keypair (Phase A1, multi-person sharing prep).
|
||||
//
|
||||
// Each account gets an X25519 keypair at registration. The PUBLIC half is
|
||||
// stored in plaintext (public keys are not secret); other accounts will wrap
|
||||
// profile DEKs to it in Phase B. The PRIVATE half is wrapped (AES-GCM
|
||||
// encrypted) under the account DEK and stored on the server as opaque
|
||||
// ciphertext. The server never sees the private key.
|
||||
//
|
||||
// See docs/adr/multi-person-sharing.md §1.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Generate an X25519 keypair for the account. Returns the public key as a
|
||||
* base64 raw string (for the server to store) and the private key as a
|
||||
* CryptoKey (kept in memory, wrapped before upload). */
|
||||
export async function generateIdentityKeyPair(): Promise<{
|
||||
publicKey: string;
|
||||
privateKey: CryptoKey;
|
||||
}> {
|
||||
const { publicKey, privateKey } = (await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'X25519' },
|
||||
true, // extractable so we can export the private key for wrapping
|
||||
['deriveBits'],
|
||||
)) as CryptoKeyPair;
|
||||
const rawPublic = await crypto.subtle.exportKey('raw', publicKey);
|
||||
return { publicKey: base64(new Uint8Array(rawPublic)), privateKey };
|
||||
}
|
||||
|
||||
/** Wrap (encrypt) the X25519 private key under the account DEK. The DEK is an
|
||||
* AES-GCM key, so we reuse the string-cipher from cipher.ts: export the private
|
||||
* key to raw bytes, base64-encode, and encrypt. */
|
||||
export async function wrapIdentityPrivateKey(
|
||||
privateKey: CryptoKey,
|
||||
dek: CryptoKey,
|
||||
): Promise<CipherPayload> {
|
||||
const rawPrivate = await crypto.subtle.exportKey('raw', privateKey);
|
||||
return encrypt(base64(new Uint8Array(rawPrivate)), dek);
|
||||
}
|
||||
|
||||
/** Unwrap (decrypt) the X25519 private key from its DEK-wrapped form. Inverse
|
||||
* of wrapIdentityPrivateKey. The result is importable for ECDH deriveBits. */
|
||||
export async function unwrapIdentityPrivateKey(
|
||||
payload: CipherPayload,
|
||||
dek: CryptoKey,
|
||||
): Promise<CryptoKey> {
|
||||
const rawPrivateB64 = await decrypt(payload, dek);
|
||||
const rawPrivate = Uint8Array.from(atob(rawPrivateB64), (c) => c.charCodeAt(0));
|
||||
return crypto.subtle.importKey(
|
||||
'raw',
|
||||
rawPrivate as BufferSource,
|
||||
{ name: 'ECDH', namedCurve: 'X25519' },
|
||||
true, // extractable so it can be re-wrapped if ever needed
|
||||
['deriveBits'],
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory key store (unchanged from Phase 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -387,67 +207,6 @@ export function hasEncKey(): boolean {
|
|||
return currentEncKey !== null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory identity-private-key store (Phase A1). Same lifecycle as the DEK:
|
||||
// held only for the authenticated session, cleared on logout, re-derived from
|
||||
// the wrapped form (under the account DEK) on unlock. Not consumed until Phase B.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let currentIdentityPrivate: CryptoKey | null = null;
|
||||
|
||||
export function setIdentityPrivate(key: CryptoKey): void {
|
||||
currentIdentityPrivate = key;
|
||||
}
|
||||
|
||||
export function getIdentityPrivate(): CryptoKey | null {
|
||||
return currentIdentityPrivate;
|
||||
}
|
||||
|
||||
export function clearIdentityPrivate(): void {
|
||||
currentIdentityPrivate = null;
|
||||
}
|
||||
|
||||
export function hasIdentityPrivate(): boolean {
|
||||
return currentIdentityPrivate !== null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory per-profile DEK store (Phase A2). One DEK per profile, plus a
|
||||
// notion of the "active" profile whose DEK encrypts/decrypts the data the UI
|
||||
// is currently showing. Same lifecycle as the account DEK: rebuilt from the
|
||||
// server-stored wrapped forms on unlock, cleared on logout.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const profileDeks: Map<string, CryptoKey> = new Map();
|
||||
let activeProfileId: string | null = null;
|
||||
|
||||
export function setProfileDek(profileId: string, dek: CryptoKey): void {
|
||||
profileDeks.set(profileId, dek);
|
||||
}
|
||||
|
||||
export function getProfileDek(profileId: string): CryptoKey | null {
|
||||
return profileDeks.get(profileId) ?? null;
|
||||
}
|
||||
|
||||
export function setActiveProfileId(profileId: string): void {
|
||||
activeProfileId = profileId;
|
||||
}
|
||||
|
||||
export function getActiveProfileId(): string | null {
|
||||
return activeProfileId;
|
||||
}
|
||||
|
||||
/** The active profile's DEK — the key all encrypt/decrypt call sites use. */
|
||||
export function getActiveProfileDek(): CryptoKey | null {
|
||||
if (activeProfileId === null) return null;
|
||||
return profileDeks.get(activeProfileId) ?? null;
|
||||
}
|
||||
|
||||
export function clearProfileDeks(): void {
|
||||
profileDeks.clear();
|
||||
activeProfileId = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backward compat: the old deriveAuthAndEncKeys (Phase 1 direct-from-password
|
||||
// enc key). Kept for tests; new code uses setupEncryption/unlockWithPassword.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { MedicationManager } from '../components/medication/MedicationManager';
|
|||
import { HealthStats } from '../components/health/HealthStats';
|
||||
import { InteractionsChecker } from '../components/interactions/InteractionsChecker';
|
||||
import { ProfileEditor } from '../components/profile/ProfileEditor';
|
||||
import { ProfileSwitcher } from '../components/profile/ProfileSwitcher';
|
||||
import { AppointmentsManager } from '../components/appointments/AppointmentsManager';
|
||||
|
||||
type TabIndex = 0 | 1 | 2 | 3 | 4;
|
||||
|
|
@ -36,7 +35,6 @@ export const Dashboard: FC = () => {
|
|||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||
Normogen
|
||||
</Typography>
|
||||
<ProfileSwitcher />
|
||||
{user && (
|
||||
<Typography variant="body2" sx={{ mr: 2, opacity: 0.85 }}>
|
||||
{user.username}
|
||||
|
|
|
|||
|
|
@ -12,23 +12,11 @@ import {
|
|||
} from '@mui/material';
|
||||
import { Lock as LockIcon } from '@mui/icons-material';
|
||||
import { useAuthStore } from '../store/useStore';
|
||||
import {
|
||||
unlockWithPassword,
|
||||
deriveAuthAndEncKeys,
|
||||
unwrapIdentityPrivateKey,
|
||||
setEncKey,
|
||||
setIdentityPrivate,
|
||||
} from '../crypto';
|
||||
import { unlockWithPassword, deriveAuthAndEncKeys, setEncKey } from '../crypto';
|
||||
|
||||
export const UnlockPage: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
wrapped_dek,
|
||||
wrapped_dek_iv,
|
||||
identity_private_key_wrapped,
|
||||
identity_private_key_wrapped_iv,
|
||||
user,
|
||||
} = useAuthStore();
|
||||
const { wrapped_dek, wrapped_dek_iv, user } = useAuthStore();
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [unlocking, setUnlocking] = useState(false);
|
||||
|
|
@ -39,32 +27,13 @@ export const UnlockPage: FC = () => {
|
|||
setUnlocking(true);
|
||||
|
||||
try {
|
||||
let dek;
|
||||
if (wrapped_dek && wrapped_dek_iv) {
|
||||
// Wrapped-DEK model: unwrap the DEK using the password.
|
||||
({ dek } = await unlockWithPassword(password, {
|
||||
const { dek } = await unlockWithPassword(password, {
|
||||
data: wrapped_dek,
|
||||
iv: wrapped_dek_iv,
|
||||
}));
|
||||
});
|
||||
setEncKey(dek);
|
||||
// Phase A1: with the DEK available, also unwrap the X25519 identity
|
||||
// private key into in-memory storage. Non-fatal if it fails (e.g.
|
||||
// legacy accounts without an identity keypair).
|
||||
if (identity_private_key_wrapped && identity_private_key_wrapped_iv) {
|
||||
try {
|
||||
const identityPrivate = await unwrapIdentityPrivateKey(
|
||||
{
|
||||
data: identity_private_key_wrapped,
|
||||
iv: identity_private_key_wrapped_iv,
|
||||
},
|
||||
dek,
|
||||
);
|
||||
setIdentityPrivate(identityPrivate);
|
||||
} catch {
|
||||
// Wrapped private key present but undecryptable — ignore; Phase B
|
||||
// features will surface a re-setup need.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Phase 1 compat: derive the key directly from the password.
|
||||
const { encKey } = await deriveAuthAndEncKeys(password);
|
||||
|
|
|
|||
|
|
@ -18,14 +18,6 @@ import {
|
|||
MedicationWireResponse,
|
||||
AppointmentWireResponse,
|
||||
ProfileWireResponse,
|
||||
CreateProfileRequest,
|
||||
UpdateProfileRequest,
|
||||
SharedProfileWireResponse,
|
||||
CreateProfileShareRequest,
|
||||
ProfileShareListing,
|
||||
PublicKeyResponse,
|
||||
RekeyRequest,
|
||||
RekeyResponse,
|
||||
HealthStatWireResponse,
|
||||
UpdateHealthStatRequest,
|
||||
EncryptedFieldWire,
|
||||
|
|
@ -240,93 +232,25 @@ class ApiService {
|
|||
});
|
||||
}
|
||||
|
||||
// ---- Profiles (Phase A2: multi-profile, per-profile DEKs) ----
|
||||
// ---- Profile (zero-knowledge: opaque encrypted name) ----
|
||||
|
||||
async listProfiles(): Promise<ProfileWireResponse[]> {
|
||||
const response = await this.client.get<ProfileWireResponse[]>('/profiles');
|
||||
async getProfile(): Promise<ProfileWireResponse> {
|
||||
const response = await this.client.get<ProfileWireResponse>('/profiles/me');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async createProfile(req: CreateProfileRequest): Promise<ProfileWireResponse> {
|
||||
const response = await this.client.post<ProfileWireResponse>('/profiles', req);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getProfile(profileId: string): Promise<ProfileWireResponse> {
|
||||
const response = await this.client.get<ProfileWireResponse>(`/profiles/${profileId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateProfile(profileId: string, req: UpdateProfileRequest): Promise<ProfileWireResponse> {
|
||||
const response = await this.client.put<ProfileWireResponse>(`/profiles/${profileId}`, req);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteProfile(profileId: string): Promise<void> {
|
||||
await this.client.delete(`/profiles/${profileId}`);
|
||||
}
|
||||
|
||||
// ---- Profile sharing (Phase B: X25519 envelope) ----
|
||||
|
||||
/** Public endpoint — recipient identity public key lookup by email. */
|
||||
async getUserPublicKey(email: string): Promise<PublicKeyResponse> {
|
||||
const response = await this.client.get<PublicKeyResponse>('/users/public-key', {
|
||||
params: { email },
|
||||
async updateProfileName(nameData: string, nameIv: string): Promise<ProfileWireResponse> {
|
||||
const response = await this.client.put<ProfileWireResponse>('/profiles/me', {
|
||||
name_data: nameData,
|
||||
name_iv: nameIv,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** Profiles shared TO the current user. Carries ECDH-wrapped profile DEKs. */
|
||||
async listSharedWithMe(): Promise<SharedProfileWireResponse[]> {
|
||||
const response = await this.client.get<SharedProfileWireResponse[]>(
|
||||
'/profiles/shared-with-me',
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** Owner: list who a profile has been shared with. */
|
||||
async listProfileShares(profileId: string): Promise<ProfileShareListing[]> {
|
||||
const response = await this.client.get<ProfileShareListing[]>(
|
||||
`/profiles/${profileId}/shares`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** Owner: share a profile. The wrapped DEK + ephemeral pubkey are produced
|
||||
* client-side via wrapProfileDekToRecipient. */
|
||||
async createProfileShare(
|
||||
profileId: string,
|
||||
req: CreateProfileShareRequest,
|
||||
): Promise<ProfileShareListing> {
|
||||
const response = await this.client.post<ProfileShareListing>(
|
||||
`/profiles/${profileId}/shares`,
|
||||
req,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** Owner: revoke a share (soft revoke — server deletes the share record). */
|
||||
async deleteProfileShare(profileId: string, recipientUserId: string): Promise<void> {
|
||||
await this.client.delete(`/profiles/${profileId}/shares/${recipientUserId}`);
|
||||
}
|
||||
|
||||
/** Owner: hard revoke / rotate the profile DEK (Phase C). The client has
|
||||
* already re-encrypted the data under a new DEK (client-side) and wrapped
|
||||
* it to the owner account DEK + each kept recipient. Recipients omitted
|
||||
* from `recipient_envelopes` are hard-revoked. */
|
||||
async rekeyProfile(profileId: string, req: RekeyRequest): Promise<RekeyResponse> {
|
||||
const response = await this.client.post<RekeyResponse>(
|
||||
`/profiles/${profileId}/rekey`,
|
||||
req,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ---- Medications (zero-knowledge: opaque encrypted blobs) ----
|
||||
|
||||
async getMedications(profileId?: string): Promise<MedicationWireResponse[]> {
|
||||
const params = profileId ? { profile_id: profileId } : undefined;
|
||||
const response = await this.client.get<MedicationWireResponse[]>('/medications', { params });
|
||||
async getMedications(): Promise<MedicationWireResponse[]> {
|
||||
const response = await this.client.get<MedicationWireResponse[]>('/medications');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
|
@ -363,12 +287,9 @@ class ApiService {
|
|||
|
||||
// ---- Appointments (zero-knowledge: opaque encrypted blobs) ----
|
||||
|
||||
async getAppointments(status?: string, profileId?: string): Promise<AppointmentWireResponse[]> {
|
||||
const params: Record<string, string> = {};
|
||||
if (status) params.status = status;
|
||||
if (profileId) params.profile_id = profileId;
|
||||
async getAppointments(status?: string): Promise<AppointmentWireResponse[]> {
|
||||
const response = await this.client.get<AppointmentWireResponse[]>('/appointments', {
|
||||
params: Object.keys(params).length ? params : undefined,
|
||||
params: status ? { status } : undefined,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
|
@ -411,9 +332,8 @@ class ApiService {
|
|||
|
||||
// ---- Health Statistics (zero-knowledge: opaque encrypted blobs) ----
|
||||
|
||||
async getHealthStats(profileId?: string): Promise<HealthStatWireResponse[]> {
|
||||
const params = profileId ? { profile_id: profileId } : undefined;
|
||||
const response = await this.client.get<HealthStatWireResponse[]>('/health-stats', { params });
|
||||
async getHealthStats(): Promise<HealthStatWireResponse[]> {
|
||||
const response = await this.client.get<HealthStatWireResponse[]>('/health-stats');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,9 +3,6 @@ import {
|
|||
deriveAuthAndEncKeys,
|
||||
setEncKey,
|
||||
clearEncKey,
|
||||
setActiveProfileId,
|
||||
setProfileDek,
|
||||
clearProfileDeks,
|
||||
encryptJson,
|
||||
setupEncryption,
|
||||
unlockWithPassword,
|
||||
|
|
@ -25,10 +22,6 @@ vi.mock('../services/api', () => ({ default: apiMock }));
|
|||
// Import the store AFTER the mock is registered.
|
||||
const { useMedicationStore } = await import('./useStore');
|
||||
|
||||
// The active profile under test — the store encrypts/decrypts with the active
|
||||
// profile's DEK, so tests must set both the profile id and its DEK.
|
||||
const TEST_PROFILE_ID = 'profile_test';
|
||||
|
||||
describe('useMedicationStore', () => {
|
||||
let encKey: CryptoKey;
|
||||
|
||||
|
|
@ -37,10 +30,6 @@ describe('useMedicationStore', () => {
|
|||
const { encKey: key } = await deriveAuthAndEncKeys('test-password');
|
||||
encKey = key;
|
||||
setEncKey(encKey);
|
||||
// Phase A2: the store uses the active profile's DEK, not the account DEK.
|
||||
// Register the test key as the active profile's DEK.
|
||||
setProfileDek(TEST_PROFILE_ID, encKey);
|
||||
setActiveProfileId(TEST_PROFILE_ID);
|
||||
|
||||
useMedicationStore.setState({
|
||||
medications: [],
|
||||
|
|
@ -55,7 +44,6 @@ describe('useMedicationStore', () => {
|
|||
|
||||
afterEach(() => {
|
||||
clearEncKey();
|
||||
clearProfileDeks();
|
||||
});
|
||||
|
||||
it('loadMedications decrypts wire responses into domain medications', async () => {
|
||||
|
|
@ -86,7 +74,6 @@ describe('useMedicationStore', () => {
|
|||
|
||||
it('loadMedications sets an error when no enc key is available', async () => {
|
||||
clearEncKey();
|
||||
clearProfileDeks();
|
||||
await useMedicationStore.getState().loadMedications();
|
||||
expect(useMedicationStore.getState().error).toMatch(/No encryption key/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,28 +6,9 @@ import {
|
|||
unlockWithPassword,
|
||||
unlockWithRecovery,
|
||||
rewrapDek,
|
||||
generateIdentityKeyPair,
|
||||
wrapIdentityPrivateKey,
|
||||
unwrapIdentityPrivateKey,
|
||||
generateProfileDek,
|
||||
wrapProfileDek,
|
||||
unwrapProfileDek,
|
||||
wrapProfileDekToRecipient,
|
||||
unwrapProfileDekFromShare,
|
||||
encrypt as encryptRaw,
|
||||
decrypt as decryptRaw,
|
||||
setEncKey,
|
||||
clearEncKey,
|
||||
clearIdentityPrivate,
|
||||
clearProfileDeks,
|
||||
setIdentityPrivate,
|
||||
getIdentityPrivate,
|
||||
getEncKey,
|
||||
getActiveProfileDek,
|
||||
getActiveProfileId,
|
||||
setActiveProfileId,
|
||||
setProfileDek,
|
||||
getProfileDek,
|
||||
encryptJson,
|
||||
decryptJson,
|
||||
type CipherPayload,
|
||||
|
|
@ -40,10 +21,6 @@ import {
|
|||
DrugInteraction,
|
||||
AdherenceStats,
|
||||
Profile,
|
||||
ProfileShareListing,
|
||||
MedicationWireResponse,
|
||||
AppointmentWireResponse,
|
||||
HealthStatWireResponse,
|
||||
Appointment,
|
||||
CreateAppointmentRequest,
|
||||
UpdateAppointmentRequest,
|
||||
|
|
@ -61,12 +38,6 @@ interface AuthState {
|
|||
// on page reload without a full re-login.
|
||||
wrapped_dek: string | null;
|
||||
wrapped_dek_iv: string | null;
|
||||
// Account X25519 identity keypair (Phase A1). The public key is plaintext;
|
||||
// the private key is the DEK-wrapped ciphertext blob. Persisted for the same
|
||||
// reason as the wrapped DEK — the unlock screen unwraps it into memory.
|
||||
identity_public_key: string | null;
|
||||
identity_private_key_wrapped: string | null;
|
||||
identity_private_key_wrapped_iv: string | null;
|
||||
|
||||
// Actions
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
|
|
@ -124,46 +95,11 @@ interface InteractionState {
|
|||
}
|
||||
|
||||
interface ProfileState {
|
||||
profiles: Profile[];
|
||||
activeProfileId: string | null;
|
||||
profile: Profile | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
/** Fetch all owned profiles and unwrap each per-profile DEK under the account
|
||||
* DEK into the in-memory crypto store. Sets the first profile active. */
|
||||
loadProfiles: () => Promise<void>;
|
||||
/** Switch the active profile (whose DEK encrypts/decrypts the UI's data). */
|
||||
setActiveProfile: (profileId: string) => void;
|
||||
createProfile: (input: {
|
||||
name: string;
|
||||
kind?: string;
|
||||
relationship?: string;
|
||||
}) => Promise<void>;
|
||||
updateProfile: (profileId: string, input: {
|
||||
name: string;
|
||||
kind?: string;
|
||||
relationship?: string;
|
||||
}) => Promise<void>;
|
||||
deleteProfile: (profileId: string) => Promise<void>;
|
||||
/** Fetch profiles shared TO the current user and merge them into `profiles`,
|
||||
* unwrapping each share's profile DEK via the recipient's identity private
|
||||
* key. Called after loadProfiles on login/unlock. */
|
||||
loadSharedWithMe: () => Promise<void>;
|
||||
/** Owner: share a profile to a recipient by email. Fetches the recipient's
|
||||
* identity public key, wraps the profile DEK to it via ECDH, and POSTs. */
|
||||
shareProfile: (profileId: string, recipientEmail: string) => Promise<void>;
|
||||
/** Owner: revoke a share (delete the share record server-side). */
|
||||
revokeShare: (profileId: string, recipientUserId: string) => Promise<void>;
|
||||
/** Shares the current user has created for a profile (for the owner UI). */
|
||||
profileShares: Record<string, ProfileShareListing[]>;
|
||||
loadProfileShares: (profileId: string) => Promise<void>;
|
||||
/** Owner: hard revoke / rotate the profile DEK. Re-encrypts all the
|
||||
* profile's data under a fresh DEK (client-side), then commits the
|
||||
* rotation server-side. Recipients not in `keepRecipientUserIds` are
|
||||
* hard-revoked (lose access at the key level). Expensive: O(data size). */
|
||||
rekeyProfile: (
|
||||
profileId: string,
|
||||
keepRecipientUserIds: string[],
|
||||
) => Promise<void>;
|
||||
loadProfile: () => Promise<void>;
|
||||
updateName: (name: string) => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -192,9 +128,6 @@ export const useAuthStore = create<AuthState>()(
|
|||
error: null,
|
||||
wrapped_dek: null,
|
||||
wrapped_dek_iv: null,
|
||||
identity_public_key: null,
|
||||
identity_private_key_wrapped: null,
|
||||
identity_private_key_wrapped_iv: null,
|
||||
|
||||
login: async (email: string, password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
|
@ -211,26 +144,6 @@ export const useAuthStore = create<AuthState>()(
|
|||
iv: response.wrapped_dek_iv,
|
||||
});
|
||||
setEncKey(dek);
|
||||
// Once the DEK is available, also unwrap the X25519 identity
|
||||
// private key (Phase A1) into in-memory storage.
|
||||
if (
|
||||
response.identity_private_key_wrapped &&
|
||||
response.identity_private_key_wrapped_iv
|
||||
) {
|
||||
try {
|
||||
const identityPrivate = await unwrapIdentityPrivateKey(
|
||||
{
|
||||
data: response.identity_private_key_wrapped,
|
||||
iv: response.identity_private_key_wrapped_iv,
|
||||
},
|
||||
dek,
|
||||
);
|
||||
setIdentityPrivate(identityPrivate);
|
||||
} catch {
|
||||
// Wrapped private key present but failed to unwrap —
|
||||
// non-fatal; Phase B features will surface a re-setup need.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fallback to the PBKDF2-derived key (Phase 1 compat).
|
||||
}
|
||||
|
|
@ -247,9 +160,6 @@ export const useAuthStore = create<AuthState>()(
|
|||
isLoading: false,
|
||||
wrapped_dek: response.wrapped_dek ?? null,
|
||||
wrapped_dek_iv: response.wrapped_dek_iv ?? null,
|
||||
identity_public_key: response.identity_public_key ?? null,
|
||||
identity_private_key_wrapped: response.identity_private_key_wrapped ?? null,
|
||||
identity_private_key_wrapped_iv: response.identity_private_key_wrapped_iv ?? null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
clearEncKey();
|
||||
|
|
@ -271,21 +181,6 @@ export const useAuthStore = create<AuthState>()(
|
|||
const setup = await setupEncryption(password, recoveryPhrase);
|
||||
setEncKey(setup.dek);
|
||||
|
||||
// Phase A1: generate the account X25519 identity keypair and wrap
|
||||
// the private half under the account DEK. The public key is sent
|
||||
// plaintext; the wrapped private key is opaque ciphertext.
|
||||
const { publicKey, privateKey } = await generateIdentityKeyPair();
|
||||
const wrappedPrivate = await wrapIdentityPrivateKey(privateKey, setup.dek);
|
||||
setIdentityPrivate(privateKey);
|
||||
|
||||
// Phase A2: generate the default "self" profile's DEK, wrap it
|
||||
// under the account DEK, and encrypt the display name (the chosen
|
||||
// username) under the profile DEK. The server stores both opaque
|
||||
// blobs verbatim and auto-creates the self profile.
|
||||
const defaultProfileDek = await generateProfileDek();
|
||||
const defaultWrapped = await wrapProfileDek(defaultProfileDek, setup.dek);
|
||||
const defaultNameBlob = await encryptRaw(username, defaultProfileDek);
|
||||
|
||||
const response = await apiService.register({
|
||||
username,
|
||||
email,
|
||||
|
|
@ -295,19 +190,7 @@ export const useAuthStore = create<AuthState>()(
|
|||
recovery_wrapped_dek: setup.recoveryWrappedDek?.data,
|
||||
recovery_wrapped_dek_iv: setup.recoveryWrappedDek?.iv,
|
||||
recovery_phrase: setup.recoveryKekHash,
|
||||
identity_public_key: publicKey,
|
||||
identity_private_key_wrapped: wrappedPrivate.data,
|
||||
identity_private_key_wrapped_iv: wrappedPrivate.iv,
|
||||
default_profile_name_data: defaultNameBlob.data,
|
||||
default_profile_name_iv: defaultNameBlob.iv,
|
||||
default_wrapped_profile_dek: defaultWrapped.data,
|
||||
default_wrapped_profile_dek_iv: defaultWrapped.iv,
|
||||
});
|
||||
// Seed the in-memory profile-DEK store with the self profile and
|
||||
// make it the active profile.
|
||||
const selfProfileId = `profile_${response.user_id}`;
|
||||
setProfileDek(selfProfileId, defaultProfileDek);
|
||||
setActiveProfileId(selfProfileId);
|
||||
} as any);
|
||||
set({
|
||||
user: {
|
||||
user_id: response.user_id,
|
||||
|
|
@ -320,9 +203,6 @@ export const useAuthStore = create<AuthState>()(
|
|||
isLoading: false,
|
||||
wrapped_dek: setup.passwordWrappedDek.data,
|
||||
wrapped_dek_iv: setup.passwordWrappedDek.iv,
|
||||
identity_public_key: publicKey,
|
||||
identity_private_key_wrapped: wrappedPrivate.data,
|
||||
identity_private_key_wrapped_iv: wrappedPrivate.iv,
|
||||
});
|
||||
} catch (error: any) {
|
||||
set({
|
||||
|
|
@ -374,8 +254,6 @@ export const useAuthStore = create<AuthState>()(
|
|||
|
||||
logout: async () => {
|
||||
clearEncKey();
|
||||
clearIdentityPrivate();
|
||||
clearProfileDeks();
|
||||
await apiService.logout();
|
||||
set({
|
||||
user: null,
|
||||
|
|
@ -384,9 +262,6 @@ export const useAuthStore = create<AuthState>()(
|
|||
error: null,
|
||||
wrapped_dek: null,
|
||||
wrapped_dek_iv: null,
|
||||
identity_public_key: null,
|
||||
identity_private_key_wrapped: null,
|
||||
identity_private_key_wrapped_iv: null,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -421,9 +296,6 @@ export const useAuthStore = create<AuthState>()(
|
|||
isAuthenticated: state.isAuthenticated,
|
||||
wrapped_dek: state.wrapped_dek,
|
||||
wrapped_dek_iv: state.wrapped_dek_iv,
|
||||
identity_public_key: state.identity_public_key,
|
||||
identity_private_key_wrapped: state.identity_private_key_wrapped,
|
||||
identity_private_key_wrapped_iv: state.identity_private_key_wrapped_iv,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
|
@ -440,14 +312,14 @@ export const useMedicationStore = create<MedicationState>()(
|
|||
adherence: {},
|
||||
|
||||
loadMedications: async () => {
|
||||
const key = getActiveProfileDek();
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||
return;
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const wireMeds = await apiService.getMedications(getActiveProfileId() ?? undefined);
|
||||
const wireMeds = await apiService.getMedications();
|
||||
// Decrypt each opaque blob into domain Medication objects.
|
||||
const medications = await Promise.all(
|
||||
wireMeds.map(async (w) => {
|
||||
|
|
@ -487,7 +359,7 @@ export const useMedicationStore = create<MedicationState>()(
|
|||
},
|
||||
|
||||
createMedication: async (data: any) => {
|
||||
const key = getActiveProfileDek();
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
|
|
@ -537,7 +409,7 @@ export const useMedicationStore = create<MedicationState>()(
|
|||
},
|
||||
|
||||
updateMedication: async (id: string, data: any) => {
|
||||
const key = getActiveProfileDek();
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
|
|
@ -636,14 +508,14 @@ export const useHealthStore = create<HealthState>()(
|
|||
error: null,
|
||||
|
||||
loadStats: async () => {
|
||||
const key = getActiveProfileDek();
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||
return;
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const wireStats = await apiService.getHealthStats(getActiveProfileId() ?? undefined);
|
||||
const wireStats = await apiService.getHealthStats();
|
||||
const stats = await Promise.all(
|
||||
wireStats.map(async (w) => {
|
||||
const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key);
|
||||
|
|
@ -670,7 +542,7 @@ export const useHealthStore = create<HealthState>()(
|
|||
},
|
||||
|
||||
createStat: async (data: any) => {
|
||||
const key = getActiveProfileDek();
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
|
|
@ -678,7 +550,6 @@ export const useHealthStore = create<HealthState>()(
|
|||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const d = data as any;
|
||||
const profileId = getActiveProfileId();
|
||||
const encrypted_data = await encryptJson({
|
||||
stat_type: d.stat_type,
|
||||
value: d.value,
|
||||
|
|
@ -686,7 +557,6 @@ export const useHealthStore = create<HealthState>()(
|
|||
notes: d.notes,
|
||||
}, key);
|
||||
const wire = await apiService.createHealthStat({
|
||||
profile_id: profileId ?? '',
|
||||
encrypted_data,
|
||||
recorded_at: d.measured_at,
|
||||
});
|
||||
|
|
@ -713,7 +583,7 @@ export const useHealthStore = create<HealthState>()(
|
|||
},
|
||||
|
||||
updateStat: async (id: string, data: any) => {
|
||||
const key = getActiveProfileDek();
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
|
|
@ -825,159 +695,71 @@ export const useInteractionStore = create<InteractionState>()(
|
|||
}))
|
||||
);
|
||||
|
||||
// Profile Store (Phase A2: multi-profile + per-profile DEKs)
|
||||
// Profile Store (Phase 3c)
|
||||
export const useProfileStore = create<ProfileState>()(
|
||||
devtools((set, get) => ({
|
||||
profiles: [],
|
||||
activeProfileId: null,
|
||||
profile: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
profileShares: {},
|
||||
|
||||
loadProfiles: async () => {
|
||||
const accountDek = getEncKey();
|
||||
if (!accountDek) {
|
||||
set({ error: 'No account key — log in to decrypt profiles', isLoading: false });
|
||||
loadProfile: async () => {
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||
return;
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const wireList = await apiService.listProfiles();
|
||||
// Unwrap each profile's DEK under the account DEK, and decrypt each
|
||||
// profile's display name under that profile's DEK.
|
||||
const profiles: Profile[] = [];
|
||||
for (const wire of wireList) {
|
||||
let profileDek = getProfileDek(wire.profile_id);
|
||||
if (!profileDek) {
|
||||
try {
|
||||
profileDek = await unwrapProfileDek(
|
||||
{ data: wire.wrapped_profile_dek, iv: wire.wrapped_profile_dek_iv },
|
||||
accountDek,
|
||||
);
|
||||
setProfileDek(wire.profile_id, profileDek);
|
||||
} catch {
|
||||
// Could not unwrap this profile's DEK — skip it; the UI will
|
||||
// show the profiles it could decrypt.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let name = '';
|
||||
if (wire.name_data && wire.name_iv && profileDek) {
|
||||
try {
|
||||
name = await decryptRaw({ data: wire.name_data, iv: wire.name_iv }, profileDek);
|
||||
} catch { name = ''; }
|
||||
}
|
||||
profiles.push({
|
||||
profile_id: wire.profile_id,
|
||||
owner_account_id: wire.owner_account_id,
|
||||
name,
|
||||
kind: wire.kind,
|
||||
relationship: wire.relationship,
|
||||
role: wire.role,
|
||||
permissions: wire.permissions,
|
||||
created_at: wire.created_at,
|
||||
updated_at: wire.updated_at,
|
||||
});
|
||||
const wire = await apiService.getProfile();
|
||||
// Decrypt the profile name.
|
||||
let name = '';
|
||||
if (wire.name_data && wire.name_iv) {
|
||||
try {
|
||||
name = await decryptJson<string>({ data: wire.name_data, iv: wire.name_iv }, key);
|
||||
} catch { name = ''; }
|
||||
}
|
||||
// Set the first profile active if none is selected (or if the active
|
||||
// one is no longer present).
|
||||
const currentActive = getActiveProfileId();
|
||||
const stillOwned = profiles.some((p) => p.profile_id === currentActive);
|
||||
const newActive = stillOwned ? currentActive : (profiles[0]?.profile_id ?? null);
|
||||
if (newActive) setActiveProfileId(newActive);
|
||||
set({ profiles, activeProfileId: newActive, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.message || 'Failed to load profiles',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setActiveProfile: (profileId) => {
|
||||
setActiveProfileId(profileId);
|
||||
set({ activeProfileId: profileId });
|
||||
},
|
||||
|
||||
createProfile: async ({ name, kind = 'human', relationship = '' }) => {
|
||||
const accountDek = getEncKey();
|
||||
if (!accountDek) {
|
||||
set({ error: 'No account key — cannot create profile' });
|
||||
throw new Error('No account key');
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
// Generate a fresh profile DEK, wrap it under the account DEK, and
|
||||
// encrypt the display name under the new profile DEK.
|
||||
const profileDek = await generateProfileDek();
|
||||
const wrapped = await wrapProfileDek(profileDek, accountDek);
|
||||
const nameBlob = await encryptRaw(name, profileDek);
|
||||
const wire = await apiService.createProfile({
|
||||
kind,
|
||||
relationship,
|
||||
name_data: nameBlob.data,
|
||||
name_iv: nameBlob.iv,
|
||||
wrapped_profile_dek: wrapped.data,
|
||||
wrapped_profile_dek_iv: wrapped.iv,
|
||||
});
|
||||
// Cache the DEK in memory (we already have it) and set this profile
|
||||
// active — the user just created it.
|
||||
setProfileDek(wire.profile_id, profileDek);
|
||||
setActiveProfileId(wire.profile_id);
|
||||
const profile: Profile = {
|
||||
profile_id: wire.profile_id,
|
||||
owner_account_id: wire.owner_account_id,
|
||||
user_id: wire.user_id,
|
||||
name,
|
||||
kind: wire.kind,
|
||||
relationship: wire.relationship,
|
||||
role: wire.role,
|
||||
permissions: wire.permissions,
|
||||
created_at: wire.created_at,
|
||||
updated_at: wire.updated_at,
|
||||
};
|
||||
set((state) => ({
|
||||
profiles: [...state.profiles, profile],
|
||||
activeProfileId: wire.profile_id,
|
||||
isLoading: false,
|
||||
}));
|
||||
set({ profile, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.message || 'Failed to create profile',
|
||||
error: error.message || 'Failed to load profile',
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateProfile: async (profileId, { name, kind, relationship }) => {
|
||||
const profileDek = getProfileDek(profileId);
|
||||
if (!profileDek) {
|
||||
set({ error: 'No profile key — switch to or unlock this profile first' });
|
||||
throw new Error('No profile key');
|
||||
updateName: async (name) => {
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const nameBlob = await encryptRaw(name, profileDek);
|
||||
const wire = await apiService.updateProfile(profileId, {
|
||||
name_data: nameBlob.data,
|
||||
name_iv: nameBlob.iv,
|
||||
kind: kind ?? 'human',
|
||||
relationship: relationship ?? '',
|
||||
});
|
||||
set((state) => ({
|
||||
profiles: state.profiles.map((p) =>
|
||||
p.profile_id === profileId
|
||||
? {
|
||||
...p,
|
||||
name,
|
||||
kind: wire.kind,
|
||||
relationship: wire.relationship,
|
||||
updated_at: wire.updated_at,
|
||||
}
|
||||
: p,
|
||||
),
|
||||
isLoading: false,
|
||||
}));
|
||||
const blob = await encryptJson(name, key);
|
||||
const wire = await apiService.updateProfileName(blob.data, blob.iv);
|
||||
let decryptedName = name;
|
||||
try {
|
||||
decryptedName = await decryptJson<string>({ data: wire.name_data, iv: wire.name_iv }, key);
|
||||
} catch { /* keep the input name */ }
|
||||
const profile: Profile = {
|
||||
profile_id: wire.profile_id,
|
||||
user_id: wire.user_id,
|
||||
name: decryptedName,
|
||||
role: wire.role,
|
||||
permissions: wire.permissions,
|
||||
created_at: wire.created_at,
|
||||
updated_at: wire.updated_at,
|
||||
};
|
||||
set({ profile, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.message || 'Failed to update profile',
|
||||
|
|
@ -987,272 +769,6 @@ export const useProfileStore = create<ProfileState>()(
|
|||
}
|
||||
},
|
||||
|
||||
deleteProfile: async (profileId) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await apiService.deleteProfile(profileId);
|
||||
const remaining = get().profiles.filter((p) => p.profile_id !== profileId);
|
||||
// If we deleted the active profile, fall back to the first remaining.
|
||||
let newActive = getActiveProfileId();
|
||||
if (newActive === profileId) {
|
||||
newActive = remaining[0]?.profile_id ?? null;
|
||||
if (newActive) setActiveProfileId(newActive);
|
||||
}
|
||||
set({ profiles: remaining, activeProfileId: newActive, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.message || 'Failed to delete profile',
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
loadSharedWithMe: async () => {
|
||||
// Recipient path: profiles shared TO the current user. Each share's
|
||||
// profile DEK is wrapped under an ECDH-derived key; unwrap it with the
|
||||
// identity private key, then decrypt the display name under the profile
|
||||
// DEK. Merge into the profiles list with is_shared = true.
|
||||
const myIdentityPrivate = getIdentityPrivate();
|
||||
if (!myIdentityPrivate) {
|
||||
// No identity key unlocked — can't unwrap shared profile DEKs. Silently
|
||||
// skip; the owned-profile list still loads.
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const shared = await apiService.listSharedWithMe();
|
||||
const sharedProfiles: Profile[] = [];
|
||||
for (const s of shared) {
|
||||
// Skip if we already have the DEK in memory (avoid re-unwrapping).
|
||||
let profileDek = getProfileDek(s.profile_id);
|
||||
if (!profileDek) {
|
||||
try {
|
||||
profileDek = await unwrapProfileDekFromShare(
|
||||
{ data: s.wrapped_profile_dek, iv: s.wrapped_profile_dek_iv },
|
||||
s.ephemeral_public_key,
|
||||
myIdentityPrivate,
|
||||
);
|
||||
setProfileDek(s.profile_id, profileDek);
|
||||
} catch {
|
||||
continue; // couldn't unwrap — skip this share
|
||||
}
|
||||
}
|
||||
let name = '';
|
||||
if (s.name_data && s.name_iv && profileDek) {
|
||||
try {
|
||||
name = await decryptRaw({ data: s.name_data, iv: s.name_iv }, profileDek);
|
||||
} catch { name = ''; }
|
||||
}
|
||||
sharedProfiles.push({
|
||||
profile_id: s.profile_id,
|
||||
owner_account_id: s.owner_account_id,
|
||||
name,
|
||||
kind: s.kind,
|
||||
relationship: s.relationship,
|
||||
role: 'patient',
|
||||
permissions: s.permissions,
|
||||
created_at: s.created_at,
|
||||
updated_at: s.created_at,
|
||||
is_shared: true,
|
||||
});
|
||||
}
|
||||
// Merge: replace any prior shared entries, keep owned ones intact.
|
||||
const owned = get().profiles.filter((p) => !p.is_shared);
|
||||
const merged = [...owned, ...sharedProfiles];
|
||||
set({ profiles: merged });
|
||||
} catch (error: any) {
|
||||
// Non-fatal: owned profiles still work.
|
||||
set({ error: error.message || 'Failed to load shared profiles' });
|
||||
}
|
||||
},
|
||||
|
||||
shareProfile: async (profileId, recipientEmail) => {
|
||||
// Owner path: fetch recipient pubkey, wrap the profile DEK to it, POST.
|
||||
const myIdentityPrivate = getIdentityPrivate();
|
||||
const profileDek = getProfileDek(profileId);
|
||||
if (!myIdentityPrivate) {
|
||||
set({ error: 'No identity key unlocked — cannot share' });
|
||||
throw new Error('No identity key');
|
||||
}
|
||||
if (!profileDek) {
|
||||
set({ error: 'No profile key — switch to the profile first' });
|
||||
throw new Error('No profile key');
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const { identity_public_key: recipientPub } =
|
||||
await apiService.getUserPublicKey(recipientEmail);
|
||||
const envelope = await wrapProfileDekToRecipient(
|
||||
profileDek,
|
||||
recipientPub,
|
||||
myIdentityPrivate,
|
||||
);
|
||||
await apiService.createProfileShare(profileId, {
|
||||
recipient_email: recipientEmail,
|
||||
ephemeral_public_key: envelope.ephemeralPublicKey,
|
||||
wrapped_profile_dek: envelope.wrappedProfileDek.data,
|
||||
wrapped_profile_dek_iv: envelope.wrappedProfileDek.iv,
|
||||
permissions: ['read'],
|
||||
});
|
||||
// Refresh the share listing for this profile.
|
||||
await get().loadProfileShares(profileId);
|
||||
set({ isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.message || 'Failed to share profile',
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
revokeShare: async (profileId, recipientUserId) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await apiService.deleteProfileShare(profileId, recipientUserId);
|
||||
await get().loadProfileShares(profileId);
|
||||
set({ isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.message || 'Failed to revoke share',
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
loadProfileShares: async (profileId) => {
|
||||
try {
|
||||
const shares = await apiService.listProfileShares(profileId);
|
||||
set((state) => ({
|
||||
profileShares: { ...state.profileShares, [profileId]: shares },
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to load shares' });
|
||||
}
|
||||
},
|
||||
|
||||
rekeyProfile: async (profileId, keepRecipientUserIds) => {
|
||||
// Phase C hard revoke. Re-encrypts all the profile's data client-side
|
||||
// under a fresh DEK, then commits the rotation. Resume-safe per-row:
|
||||
// if a row was already re-encrypted on a prior partial run, decrypt-
|
||||
// with-old fails and we try decrypt-with-new; if that succeeds, the
|
||||
// row is already on the new DEK and we skip it.
|
||||
const accountDek = getEncKey();
|
||||
const oldDek = getProfileDek(profileId);
|
||||
const myIdentityPrivate = getIdentityPrivate();
|
||||
if (!accountDek || !oldDek) {
|
||||
set({ error: 'Unlock the profile first (no account/profile key)' });
|
||||
throw new Error('No keys');
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const newDek = await generateProfileDek();
|
||||
|
||||
// 3a. Re-encrypt every data row under the new DEK.
|
||||
// medications + appointments: POST /:id with the new blob.
|
||||
// health-stats: PUT /:id with the new blob.
|
||||
const reencryptRow = async (
|
||||
getBlob: () => Promise<{ data: string; iv: string } | null>,
|
||||
reencrypt: (newBlob: { data: string; iv: string }) => Promise<void>,
|
||||
) => {
|
||||
const blob = await getBlob();
|
||||
if (!blob) return;
|
||||
let plaintext: string;
|
||||
try {
|
||||
plaintext = await decryptRaw(blob, oldDek);
|
||||
} catch {
|
||||
// Maybe already re-encrypted on a prior partial run — verify with
|
||||
// the new DEK, and skip if so.
|
||||
try {
|
||||
await decryptRaw(blob, newDek);
|
||||
return; // already on newDek
|
||||
} catch {
|
||||
throw new Error('row could not be decrypted with old or new DEK');
|
||||
}
|
||||
}
|
||||
const newBlob = await encryptRaw(plaintext, newDek);
|
||||
await reencrypt(newBlob);
|
||||
};
|
||||
|
||||
const meds: MedicationWireResponse[] = await apiService.getMedications(profileId);
|
||||
for (const m of meds) {
|
||||
await reencryptRow(
|
||||
async () => (m.encrypted_data?.data ? m.encrypted_data : null),
|
||||
async (newBlob) => {
|
||||
await apiService.updateMedication(m.medication_id, {
|
||||
encrypted_data: newBlob,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
const appts: AppointmentWireResponse[] = await apiService.getAppointments(
|
||||
undefined,
|
||||
profileId,
|
||||
);
|
||||
for (const a of appts) {
|
||||
await reencryptRow(
|
||||
async () => (a.encrypted_data?.data ? a.encrypted_data : null),
|
||||
async (newBlob) => {
|
||||
await apiService.updateAppointment(a.appointment_id, { encrypted_data: newBlob });
|
||||
},
|
||||
);
|
||||
}
|
||||
const stats: HealthStatWireResponse[] = await apiService.getHealthStats(profileId);
|
||||
for (const s of stats) {
|
||||
await reencryptRow(
|
||||
async () => (s.encrypted_data?.data ? s.encrypted_data : null),
|
||||
async (newBlob) => {
|
||||
await apiService.updateHealthStat(s.id, { encrypted_data: newBlob });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 3b. Build a fresh ECDH envelope per kept recipient.
|
||||
const recipientEnvelopes = [];
|
||||
if (myIdentityPrivate) {
|
||||
const shares = get().profileShares[profileId] ?? [];
|
||||
for (const s of shares) {
|
||||
if (!keepRecipientUserIds.includes(s.recipient_user_id)) continue;
|
||||
const { identity_public_key: recipientPub } = await apiService.getUserPublicKey(
|
||||
s.recipient_email,
|
||||
);
|
||||
const env = await wrapProfileDekToRecipient(
|
||||
newDek,
|
||||
recipientPub,
|
||||
myIdentityPrivate,
|
||||
);
|
||||
recipientEnvelopes.push({
|
||||
recipient_user_id: s.recipient_user_id,
|
||||
ephemeral_public_key: env.ephemeralPublicKey,
|
||||
wrapped_profile_dek: env.wrappedProfileDek.data,
|
||||
wrapped_profile_dek_iv: env.wrappedProfileDek.iv,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3c. Commit: rotate the owner share (new DEK wrapped under the account
|
||||
// DEK) + recipient envelopes, hard-delete omitted recipients.
|
||||
const ownerWrap = await wrapProfileDek(newDek, accountDek);
|
||||
await apiService.rekeyProfile(profileId, {
|
||||
new_wrapped_profile_dek: ownerWrap.data,
|
||||
new_wrapped_profile_dek_iv: ownerWrap.iv,
|
||||
recipient_envelopes: recipientEnvelopes,
|
||||
});
|
||||
|
||||
// 3d. Swap the in-memory DEK and refresh the share listing.
|
||||
setProfileDek(profileId, newDek);
|
||||
await get().loadProfileShares(profileId);
|
||||
set({ isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.message || 'Re-key failed (you can retry — already-re-encrypted rows are skipped)',
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
})),
|
||||
);
|
||||
|
|
@ -1265,14 +781,14 @@ export const useAppointmentStore = create<AppointmentState>()(
|
|||
error: null,
|
||||
|
||||
loadAppointments: async (status) => {
|
||||
const key = getActiveProfileDek();
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||
return;
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const wireAppts = await apiService.getAppointments(status, getActiveProfileId() ?? undefined);
|
||||
const wireAppts = await apiService.getAppointments(status);
|
||||
const appointments = await Promise.all(
|
||||
wireAppts.map(async (w) => {
|
||||
const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key);
|
||||
|
|
@ -1305,7 +821,7 @@ export const useAppointmentStore = create<AppointmentState>()(
|
|||
},
|
||||
|
||||
createAppointment: async (data) => {
|
||||
const key = getActiveProfileDek();
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
|
|
@ -1351,7 +867,7 @@ export const useAppointmentStore = create<AppointmentState>()(
|
|||
},
|
||||
|
||||
updateAppointment: async (id, data) => {
|
||||
const key = getActiveProfileDek();
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
|
|
|
|||
|
|
@ -42,11 +42,6 @@ export interface AuthTokens {
|
|||
email?: string;
|
||||
wrapped_dek?: string;
|
||||
wrapped_dek_iv?: string;
|
||||
/** Account X25519 identity keypair (Phase A1). Public key is plaintext;
|
||||
* private key is wrapped under the account DEK. Absent for legacy accounts. */
|
||||
identity_public_key?: string;
|
||||
identity_private_key_wrapped?: string;
|
||||
identity_private_key_wrapped_iv?: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
|
|
@ -59,25 +54,6 @@ export interface RegisterRequest {
|
|||
email: string;
|
||||
password: string;
|
||||
role?: 'patient' | 'provider' | 'admin';
|
||||
/** Zero-knowledge wrapped DEK (password KEK). */
|
||||
wrapped_dek?: string;
|
||||
wrapped_dek_iv?: string;
|
||||
/** Zero-knowledge wrapped DEK (recovery KEK). */
|
||||
recovery_wrapped_dek?: string;
|
||||
recovery_wrapped_dek_iv?: string;
|
||||
/** Recovery proof (PBKDF2 derivation of the recovery phrase). */
|
||||
recovery_phrase?: string;
|
||||
/** Account X25519 identity keypair (Phase A1). */
|
||||
identity_public_key?: string;
|
||||
identity_private_key_wrapped?: string;
|
||||
identity_private_key_wrapped_iv?: string;
|
||||
/** Default "self" profile (Phase A2). Encrypted name blob + profile DEK
|
||||
* wrapped under the account DEK. */
|
||||
default_profile_name_data?: string;
|
||||
default_profile_name_iv?: string;
|
||||
default_profile_name_auth_tag?: string;
|
||||
default_wrapped_profile_dek?: string;
|
||||
default_wrapped_profile_dek_iv?: string;
|
||||
}
|
||||
|
||||
// Medication Types
|
||||
|
|
@ -247,13 +223,11 @@ export interface HealthStat {
|
|||
export interface HealthStatWireResponse {
|
||||
id: string;
|
||||
user_id: string;
|
||||
profile_id: string;
|
||||
encrypted_data: EncryptedFieldWire;
|
||||
recorded_at: string;
|
||||
}
|
||||
|
||||
export interface CreateHealthStatRequest {
|
||||
profile_id: string;
|
||||
encrypted_data: EncryptedFieldWire;
|
||||
recorded_at?: string;
|
||||
}
|
||||
|
|
@ -333,110 +307,18 @@ export interface UpdateAppointmentRequest {
|
|||
status?: string;
|
||||
}
|
||||
|
||||
// Profile wire response (opaque encrypted name + wrapped profile DEK)
|
||||
// Profile wire response (opaque encrypted name)
|
||||
export interface ProfileWireResponse {
|
||||
profile_id: string;
|
||||
owner_account_id: string;
|
||||
user_id: string;
|
||||
name_data: string;
|
||||
name_iv: string;
|
||||
kind: string;
|
||||
relationship: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
/** Profile DEK wrapped under the account DEK (opaque ciphertext). */
|
||||
wrapped_profile_dek: string;
|
||||
wrapped_profile_dek_iv: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Request body for POST /profiles
|
||||
export interface CreateProfileRequest {
|
||||
profile_id?: string;
|
||||
kind?: string;
|
||||
relationship?: string;
|
||||
name_data: string;
|
||||
name_iv: string;
|
||||
name_auth_tag?: string;
|
||||
wrapped_profile_dek: string;
|
||||
wrapped_profile_dek_iv: string;
|
||||
}
|
||||
|
||||
// Request body for PUT /profiles/:id
|
||||
export interface UpdateProfileRequest {
|
||||
name_data: string;
|
||||
name_iv: string;
|
||||
name_auth_tag?: string;
|
||||
kind?: string;
|
||||
relationship?: string;
|
||||
}
|
||||
|
||||
// A profile shared TO the current user (recipient view). Carries the
|
||||
// per-share ECDH-wrapped DEK the recipient unwraps with their identity
|
||||
// private key (not the account-wrapped form owned profiles use).
|
||||
export interface SharedProfileWireResponse {
|
||||
profile_id: string;
|
||||
owner_account_id: string;
|
||||
name_data: string;
|
||||
name_iv: string;
|
||||
kind: string;
|
||||
relationship: string;
|
||||
ephemeral_public_key: string;
|
||||
wrapped_profile_dek: string;
|
||||
wrapped_profile_dek_iv: string;
|
||||
permissions: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Request body for POST /profiles/:id/shares (owner wraps client-side).
|
||||
export interface CreateProfileShareRequest {
|
||||
recipient_email: string;
|
||||
ephemeral_public_key: string;
|
||||
wrapped_profile_dek: string;
|
||||
wrapped_profile_dek_iv: string;
|
||||
permissions?: string[];
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
// A share the owner has created (listing view).
|
||||
export interface ProfileShareListing {
|
||||
profile_id: string;
|
||||
recipient_user_id: string;
|
||||
recipient_email: string;
|
||||
permissions: string[];
|
||||
created_at: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
// Phase C hard revoke: one fresh ECDH envelope per recipient the owner keeps.
|
||||
export interface RecipientEnvelope {
|
||||
recipient_user_id: string;
|
||||
ephemeral_public_key: string;
|
||||
wrapped_profile_dek: string;
|
||||
wrapped_profile_dek_iv: string;
|
||||
}
|
||||
|
||||
// Request body for POST /profiles/:id/rekey (hard revoke / rotate the DEK).
|
||||
export interface RekeyRequest {
|
||||
new_wrapped_profile_dek: string;
|
||||
new_wrapped_profile_dek_iv: string;
|
||||
recipient_envelopes: RecipientEnvelope[];
|
||||
}
|
||||
|
||||
// Response from POST /profiles/:id/rekey.
|
||||
export interface RekeyResponse {
|
||||
profile_id: string;
|
||||
wrapped_profile_dek: string;
|
||||
wrapped_profile_dek_iv: string;
|
||||
retained_recipient_user_ids: string[];
|
||||
}
|
||||
|
||||
// Response from GET /users/public-key?email=...
|
||||
export interface PublicKeyResponse {
|
||||
user_id: string;
|
||||
identity_public_key: string;
|
||||
}
|
||||
|
||||
// Dose Log Types — match backend MedicationDose (camelCase serialization).
|
||||
export interface DoseLog {
|
||||
id?: string;
|
||||
|
|
@ -469,18 +351,12 @@ export interface AdherenceStats {
|
|||
// Profile Types — match backend ProfileResponse.
|
||||
export interface Profile {
|
||||
profile_id: string;
|
||||
owner_account_id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
relationship: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
/** True if this profile is shared TO the current user (recipient view).
|
||||
* Undefined/false for profiles the user owns. Set by the store when merging
|
||||
* shared-with-me results. */
|
||||
is_shared?: boolean;
|
||||
}
|
||||
|
||||
// Error Types
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue