Compare commits

..

5 commits

Author SHA1 Message Date
dabbea2483 Merge pull request 'feat: hard revoke / re-key (Phase C, #3)' (#15) from feat/3-phase-c-rekey into main
All checks were successful
Lint and Build / format (push) Successful in 36s
Lint and Build / clippy (push) Successful in 1m38s
Lint and Build / build (push) Successful in 3m48s
Lint and Build / test (push) Successful in 4m20s
2026-07-20 01:32:55 +00:00
goose
6d6af67f2f fix(rekey): echo stored DEK values in response, not the pre-image
All checks were successful
Lint and Build / format (pull_request) Successful in 1m1s
Lint and Build / clippy (pull_request) Successful in 1m44s
Lint and Build / build (pull_request) Successful in 3m49s
Lint and Build / test (pull_request) Successful in 4m21s
rekey_rotates_owner_share_and_keeps_listed_recipients failed in CI:
the response's wrapped_profile_dek was 'owner-dek-v1' (old) instead of
'owner-dek-v2' (new). update_wrapped_dek uses find_one_and_update,
which returns the document as it was BEFORE the update by default —
so reading the new value from the returned doc gives the pre-image.

Fix: echo the request values (exactly what was stored) in the response
instead of reading them back. Unambiguous and avoids the
ReturnDocument::After plumbing. The write itself was always correct
(the stored value was v2) — only the response was stale, which is why
the other rekey tests (which check state via fresh reads, not the
response body) passed.

Now CI-green (clippy/fmt clean; integration tests will re-run).
2026-07-19 22:13:51 -03:00
goose
bf5aeedbc2 feat: hard revoke / re-key (Phase C, #3)
Some checks failed
Lint and Build / format (pull_request) Successful in 40s
Lint and Build / clippy (pull_request) Successful in 1m56s
Lint and Build / build (pull_request) Successful in 3m47s
Lint and Build / test (pull_request) Failing after 3m51s
An owner can now rotate a profile's DEK — generating a fresh key,
re-encrypting all the profile's data under it (client-side), and
re-wrapping to the owner share + kept recipients. A revoked recipient's
cached old DEK stops working, closing the soft-revoke window the ADR
requires for graduation / suspected compromise.

The server stays a blind store: it sees opaque old→new ciphertext blobs
flow through, never the DEK or plaintext. No backend crypto.

Backend:
- ProfileRepository::update_wrapped_dek — rotate the owner share
  (account-wrapped profile DEK), owner-scoped.
- ProfileShareRepository::find_active_for_profile +
  delete_for_profile_excluding — list current recipients and hard-delete
  omitted ones.
- POST /api/profiles/:id/rekey: validates each submitted envelope
  against existing active shares (no smuggling new recipients in via
  rekey), rotates the owner share, upserts recipient envelopes with
  fresh ephemeral ECDH keys, hard-deletes omitted recipients.
- rekey_tests.rs: rotation, hard-revoke-from-recipient-view, no-share
  rejection, non-owner rejection, revoke-all.

Frontend:
- useProfileStore.rekeyProfile: fetches all profile data, re-encrypts
  each row under a new DEK (resume-safe — rows already on the new DEK
  are skipped), builds fresh ECDH envelopes per kept recipient, commits
  via POST /rekey, swaps the in-memory DEK.
- ProfileSharing: 'Rotate encryption key (hard revoke)' action with a
  strong confirmation dialog explaining the cost and the resume-safe
  retry.

Best-effort + resumable retry (per design decision); no server-side
write lock.

Verification: backend cargo build/clippy (-D warnings)/fmt clean, tests
compile (integration tests run in CI — Mongo is fixed there). Frontend
tsc clean, 31/31 tests pass.

⚠️ Backend integration tests not run locally (no Mongo in this sandbox);
CI will run them — I'll fix any failures.

Admin-initiated rekey (ADR §5c reserves the permission) is out of scope
— handler is owner-only until admin shares are creatable in Phase D.
Refs #3.
2026-07-19 15:15:39 -03:00
goose
668ea59030 chore: remove dead AccessClaims struct (#5)
All checks were successful
Lint and Build / format (pull_request) Successful in 36s
Lint and Build / clippy (pull_request) Successful in 1m42s
Lint and Build / build (pull_request) Successful in 3m47s
Lint and Build / test (pull_request) Successful in 3m57s
Lint and Build / format (push) Successful in 38s
Lint and Build / clippy (push) Successful in 1m58s
Lint and Build / build (push) Successful in 3m48s
Lint and Build / test (push) Successful in 3m57s
backend/src/auth/claims.rs was orphaned dead code: auth/mod.rs never
declared 'mod claims', so the file wasn't compiled into the crate at
all. It defined two structs:
- AccessClaims — referenced nowhere; a latent trap (carried
  family_id/permissions fields that suggested enforcement that doesn't
  exist).
- RefreshClaims — a duplicate of the LIVE RefreshClaims in jwt.rs,
  which is what JwtService actually uses.

Deleted the file. Updated the JWT ADR to mark the open item done and
note the removal historically.

Build/clippy/fmt clean (the file wasn't compiled anyway). Closes #5.
2026-07-19 12:23:31 -03:00
76fd5a8c26 Merge pull request 'fix(security): enforce ownership on medication/appointment write paths (#12)' (#13) from fix/12-write-path-ownership into main
All checks were successful
Lint and Build / format (push) Successful in 35s
Lint and Build / clippy (push) Successful in 1m40s
Lint and Build / build (push) Successful in 3m47s
Lint and Build / test (push) Successful in 3m59s
Reviewed-on: #13
2026-07-19 15:20:14 +00:00
12 changed files with 874 additions and 31 deletions

View file

@ -66,6 +66,8 @@ pub fn build_app(state: AppState) -> Router {
get(handlers::list_shares).post(handlers::create_share),
)
.route("/api/profiles/:id/shares/:recipient", delete(handlers::delete_share))
// Hard revoke / rotate the profile DEK (Phase C). Owner-only.
.route("/api/profiles/:id/rekey", post(handlers::rekey_profile))
// Session management (Phase 2.6)
.route("/api/sessions", get(handlers::get_sessions))
.route("/api/sessions/:id", delete(handlers::revoke_session))

View file

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

View file

@ -26,7 +26,7 @@ pub use medications::{
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,
list_shares, rekey_profile,
};
pub use sessions::{get_sessions, revoke_all_sessions, revoke_session};
pub use users::{

View file

@ -511,3 +511,198 @@ pub async fn get_public_key(
identity_public_key: pk,
}))
}
// ---------------------------------------------------------------------------
// POST /api/profiles/:id/rekey — hard revoke / rotate the profile DEK (Phase C).
//
// The owner's client has already (a) generated a fresh profile DEK, (b)
// re-encrypted all the profile's data under it (client-side), and (c) wrapped
// the new DEK to its own account DEK + to each still-valid recipient's identity
// public key via fresh ECDH envelopes. This call commits the rotation: it
// stores the new owner-wrapped DEK, upserts each submitted recipient envelope,
// and hard-deletes any current recipient NOT in the submitted list (the
// hard-revoke). The server stores only opaque blobs + public keys.
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct RecipientEnvelope {
pub recipient_user_id: String,
pub ephemeral_public_key: String,
pub wrapped_profile_dek: String,
pub wrapped_profile_dek_iv: String,
}
#[derive(Debug, Deserialize)]
pub struct RekeyRequest {
/// The NEW profile DEK, wrapped under the owner's account DEK. Opaque.
pub new_wrapped_profile_dek: String,
pub new_wrapped_profile_dek_iv: String,
/// One fresh ECDH envelope per recipient the owner wants to KEEP. Any
/// current recipient not listed here is hard-revoked.
#[serde(default)]
pub recipient_envelopes: Vec<RecipientEnvelope>,
}
#[derive(Debug, Serialize)]
pub struct RekeyResponse {
pub profile_id: String,
pub wrapped_profile_dek: String,
pub wrapped_profile_dek_iv: String,
/// The recipients still sharing this profile after the rotation.
pub retained_recipient_user_ids: Vec<String>,
}
pub async fn rekey_profile(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Path(profile_id): Path<String>,
Json(req): Json<RekeyRequest>,
) -> Result<(StatusCode, Json<RekeyResponse>), (StatusCode, Json<serde_json::Value>)> {
// 1. Owner-only. (Admin-tier rekey is reserved for a later phase; the
// handler is owner-only for now.)
let profiles = profile_repo(&state);
let owner = match profiles
.find_by_profile_id_owned(&profile_id, &claims.sub)
.await
{
Ok(Some(p)) => p.owner_account_id,
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "profile not found" })),
));
}
Err(e) => {
tracing::error!("rekey profile lookup failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
// 2. Validate each submitted envelope: the owner can only rotate envelopes
// for recipients who ALREADY have an active share. (Adding a new share
// goes through POST /profiles/:id/shares, not rekey.) This prevents
// smuggling in a brand-new recipient via the rekey call.
let shares = share_repo(&state);
let active_shares = match shares.find_active_for_profile(&profile_id).await {
Ok(v) => v,
Err(e) => {
tracing::error!("rekey active-share lookup failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
let active_recipient_ids: std::collections::HashSet<&str> = active_shares
.iter()
.map(|s| s.recipient_user_id.as_str())
.collect();
for env in &req.recipient_envelopes {
if !active_recipient_ids.contains(env.recipient_user_id.as_str()) {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "recipient_envelope references a recipient with no active share; use POST /profiles/:id/shares to add a share",
"recipient_user_id": env.recipient_user_id,
})),
));
}
}
// 3. Rotate the owner share (store the new account-wrapped profile DEK).
// update_wrapped_dek uses find_one_and_update, which returns the PRE-image
// by default — so we don't read the new values back from it; we echo the
// request values (exactly what was stored) in the response below.
match profiles
.update_wrapped_dek(
&profile_id,
&owner,
&req.new_wrapped_profile_dek,
&req.new_wrapped_profile_dek_iv,
)
.await
{
Ok(Some(_)) => {}
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "profile not found" })),
));
}
Err(e) => {
tracing::error!("rekey owner-share rotation failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
}
// 4. Upsert each submitted recipient envelope (fresh ECDH-wrapped DEK +
// ephemeral pubkey, replacing the old envelope). Preserve the original
// share's permissions / expires_at by reading them from the active share.
let now = DateTime::now();
for env in &req.recipient_envelopes {
let original = active_shares
.iter()
.find(|s| s.recipient_user_id == env.recipient_user_id);
let permissions = original
.map(|s| s.permissions.clone())
.unwrap_or_else(|| vec!["read".to_string()]);
let expires_at = original.and_then(|s| s.expires_at);
let new_share = ProfileShare {
id: None,
profile_id: profile_id.clone(),
owner_user_id: owner.clone(),
recipient_user_id: env.recipient_user_id.clone(),
ephemeral_public_key: env.ephemeral_public_key.clone(),
wrapped_profile_dek: env.wrapped_profile_dek.clone(),
wrapped_profile_dek_iv: env.wrapped_profile_dek_iv.clone(),
permissions,
expires_at,
created_at: now,
active: true,
};
if let Err(e) = shares.upsert(&new_share).await {
tracing::error!("rekey recipient upsert failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
}
// 5. Hard-revoke any current recipient NOT in the submitted list. Their
// cached old DEK no longer matches the rotated profile DEK, AND the
// server stops serving them.
let keep: Vec<String> = req
.recipient_envelopes
.iter()
.map(|e| e.recipient_user_id.clone())
.collect();
if let Err(e) = shares
.delete_for_profile_excluding(&profile_id, &keep)
.await
{
tracing::error!("rekey hard-revoke delete failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
Ok((
StatusCode::OK,
Json(RekeyResponse {
profile_id,
// Echo the request values (exactly what was stored). Avoids the
// find_one_and_update pre-image gotcha.
wrapped_profile_dek: req.new_wrapped_profile_dek,
wrapped_profile_dek_iv: req.new_wrapped_profile_dek_iv,
retained_recipient_user_ids: keep,
}),
))
}

View file

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

View file

@ -142,6 +142,49 @@ impl ProfileShareRepository {
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(

View file

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

View file

@ -65,10 +65,10 @@ Note what is **absent** and why:
enforcement is not implemented at the auth layer (see issue #3 — multi-person
sharing is an open design problem). Putting unenforced claims in the token
would imply protection that doesn't exist.
- `backend/src/auth/claims.rs` *does* define an `AccessClaims` struct with
`family_id`, `permissions`, `token_type`, `jti`. **It is dead code** — not
imported or used anywhere in the source (only in stale compiler scratch
files under `target/`). It should be removed or wired up; tracked by issue #4.
- (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.
@ -151,13 +151,13 @@ never on the server to protect:
- HS256 with a shared secret means the secret is sensitive infrastructure; a
key rotation requires invalidating all tokens (acceptable for the deployment
model, but worth noting vs. asymmetric RS/ES keys).
- The dead `AccessClaims` struct is a latent trap — someone could wire it up
assuming `family_id`/`permissions` are enforced. Should be deleted.
## Open items
- **Remove `backend/src/auth/claims.rs`** (dead `AccessClaims`) or wire it up
intentionally. Tracked under issue #4.
- ~~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.

View file

@ -15,6 +15,7 @@ import {
} 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
@ -27,6 +28,7 @@ export const ProfileSharing: FC<{ profileId: string }> = ({ profileId }) => {
loadProfileShares,
shareProfile,
revokeShare,
rekeyProfile,
error,
clearError,
} = useProfileStore();
@ -66,6 +68,36 @@ export const ProfileSharing: FC<{ profileId: string }> = ({ profileId }) => {
}
};
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 }}>
@ -133,6 +165,23 @@ export const ProfileSharing: FC<{ profileId: string }> = ({ profileId }) => {
<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>
);
};

View file

@ -24,6 +24,8 @@ import {
CreateProfileShareRequest,
ProfileShareListing,
PublicKeyResponse,
RekeyRequest,
RekeyResponse,
HealthStatWireResponse,
UpdateHealthStatRequest,
EncryptedFieldWire,
@ -308,6 +310,18 @@ class ApiService {
await this.client.delete(`/profiles/${profileId}/shares/${recipientUserId}`);
}
/** Owner: hard revoke / rotate the profile DEK (Phase C). The client has
* already re-encrypted the data under a new DEK (client-side) and wrapped
* it to the owner account DEK + each kept recipient. Recipients omitted
* from `recipient_envelopes` are hard-revoked. */
async rekeyProfile(profileId: string, req: RekeyRequest): Promise<RekeyResponse> {
const response = await this.client.post<RekeyResponse>(
`/profiles/${profileId}/rekey`,
req,
);
return response.data;
}
// ---- Medications (zero-knowledge: opaque encrypted blobs) ----
async getMedications(profileId?: string): Promise<MedicationWireResponse[]> {

View file

@ -41,6 +41,9 @@ import {
AdherenceStats,
Profile,
ProfileShareListing,
MedicationWireResponse,
AppointmentWireResponse,
HealthStatWireResponse,
Appointment,
CreateAppointmentRequest,
UpdateAppointmentRequest,
@ -153,6 +156,14 @@ interface ProfileState {
/** Shares the current user has created for a profile (for the owner UI). */
profileShares: Record<string, ProfileShareListing[]>;
loadProfileShares: (profileId: string) => Promise<void>;
/** Owner: hard revoke / rotate the profile DEK. Re-encrypts all the
* profile's data under a fresh DEK (client-side), then commits the
* rotation server-side. Recipients not in `keepRecipientUserIds` are
* hard-revoked (lose access at the key level). Expensive: O(data size). */
rekeyProfile: (
profileId: string,
keepRecipientUserIds: string[],
) => Promise<void>;
clearError: () => void;
}
@ -1121,6 +1132,127 @@ export const useProfileStore = create<ProfileState>()(
}
},
rekeyProfile: async (profileId, keepRecipientUserIds) => {
// Phase C hard revoke. Re-encrypts all the profile's data client-side
// under a fresh DEK, then commits the rotation. Resume-safe per-row:
// if a row was already re-encrypted on a prior partial run, decrypt-
// with-old fails and we try decrypt-with-new; if that succeeds, the
// row is already on the new DEK and we skip it.
const accountDek = getEncKey();
const oldDek = getProfileDek(profileId);
const myIdentityPrivate = getIdentityPrivate();
if (!accountDek || !oldDek) {
set({ error: 'Unlock the profile first (no account/profile key)' });
throw new Error('No keys');
}
set({ isLoading: true, error: null });
try {
const newDek = await generateProfileDek();
// 3a. Re-encrypt every data row under the new DEK.
// medications + appointments: POST /:id with the new blob.
// health-stats: PUT /:id with the new blob.
const reencryptRow = async (
getBlob: () => Promise<{ data: string; iv: string } | null>,
reencrypt: (newBlob: { data: string; iv: string }) => Promise<void>,
) => {
const blob = await getBlob();
if (!blob) return;
let plaintext: string;
try {
plaintext = await decryptRaw(blob, oldDek);
} catch {
// Maybe already re-encrypted on a prior partial run — verify with
// the new DEK, and skip if so.
try {
await decryptRaw(blob, newDek);
return; // already on newDek
} catch {
throw new Error('row could not be decrypted with old or new DEK');
}
}
const newBlob = await encryptRaw(plaintext, newDek);
await reencrypt(newBlob);
};
const meds: MedicationWireResponse[] = await apiService.getMedications(profileId);
for (const m of meds) {
await reencryptRow(
async () => (m.encrypted_data?.data ? m.encrypted_data : null),
async (newBlob) => {
await apiService.updateMedication(m.medication_id, {
encrypted_data: newBlob,
});
},
);
}
const appts: AppointmentWireResponse[] = await apiService.getAppointments(
undefined,
profileId,
);
for (const a of appts) {
await reencryptRow(
async () => (a.encrypted_data?.data ? a.encrypted_data : null),
async (newBlob) => {
await apiService.updateAppointment(a.appointment_id, { encrypted_data: newBlob });
},
);
}
const stats: HealthStatWireResponse[] = await apiService.getHealthStats(profileId);
for (const s of stats) {
await reencryptRow(
async () => (s.encrypted_data?.data ? s.encrypted_data : null),
async (newBlob) => {
await apiService.updateHealthStat(s.id, { encrypted_data: newBlob });
},
);
}
// 3b. Build a fresh ECDH envelope per kept recipient.
const recipientEnvelopes = [];
if (myIdentityPrivate) {
const shares = get().profileShares[profileId] ?? [];
for (const s of shares) {
if (!keepRecipientUserIds.includes(s.recipient_user_id)) continue;
const { identity_public_key: recipientPub } = await apiService.getUserPublicKey(
s.recipient_email,
);
const env = await wrapProfileDekToRecipient(
newDek,
recipientPub,
myIdentityPrivate,
);
recipientEnvelopes.push({
recipient_user_id: s.recipient_user_id,
ephemeral_public_key: env.ephemeralPublicKey,
wrapped_profile_dek: env.wrappedProfileDek.data,
wrapped_profile_dek_iv: env.wrappedProfileDek.iv,
});
}
}
// 3c. Commit: rotate the owner share (new DEK wrapped under the account
// DEK) + recipient envelopes, hard-delete omitted recipients.
const ownerWrap = await wrapProfileDek(newDek, accountDek);
await apiService.rekeyProfile(profileId, {
new_wrapped_profile_dek: ownerWrap.data,
new_wrapped_profile_dek_iv: ownerWrap.iv,
recipient_envelopes: recipientEnvelopes,
});
// 3d. Swap the in-memory DEK and refresh the share listing.
setProfileDek(profileId, newDek);
await get().loadProfileShares(profileId);
set({ isLoading: false });
} catch (error: any) {
set({
error: error.message || 'Re-key failed (you can retry — already-re-encrypted rows are skipped)',
isLoading: false,
});
throw error;
}
},
clearError: () => set({ error: null }),
})),
);

View file

@ -408,6 +408,29 @@ export interface ProfileShareListing {
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;