feat: per-profile DEKs + multi-profile (Phase A2, #3) #9

Merged
alvaro merged 2 commits from feat/3-phase-a2-per-profile-deks into main 2026-07-19 09:46:54 +00:00
Owner

Summary

Phase A2 of the multi-person sharing ADR (docs/adr/multi-person-sharing.md). Implements the 3-tier key model: each account owns multiple profiles (a person or pet — a "subject of care"), and each profile has its own random AES-256-GCM DEK. All health data is now encrypted under the active profile's DEK, not the account-wide DEK. The account DEK wraps each profile DEK; the server stores only opaque wrapped blobs.

This is the largest piece of the ADR. Per the ADR: DB wipe, no migration (no real user data). It unblocks Phase B (sharing) — there is now a per-profile key to wrap to a recipient's X25519 public key.

Decisions (from clarification)

  • One PR (backend + frontend together)
  • Health stats gain profile_id (were previously unbound)
  • /api/profiles/me removed — no compat shim; the client uses GET /api/profiles (list) + active-profile selection

Backend

  • models/profile.rs: Profile gains owner_account_id, kind (human/pet), relationship, wrapped_profile_dek + iv. ProfileRepository gains find_all_by_owner, find_by_profile_id_owned, update_profile, delete_profile — all ownership-scoped (queries filter on ownerAccountId).
  • handlers/profile.rs: full multi-profile CRUD — GET /api/profiles (list), POST /api/profiles (create), GET/PUT/DELETE /api/profiles/:id. Removed /api/profiles/me.
  • handlers/auth.rs: register accepts default_profile_* fields and auto-creates the self profile when the client provides a wrapped profile DEK.
  • Health stats + appointments: both gain profile_id and ?profile_id= filtering (health stats previously had no profile binding at all).
  • Rename: handlers/users.rs get_profile/update_profile (the /api/users/me handlers) → get_account/update_account, to resolve a name collision with the new profile handlers. They were misnamed — they operate on the user account, not a profile.
  • New profile_tests.rs: multi-profile CRUD, ownership isolation (user B cannot read/update/delete user A's profile), and register-with-default-profile. Fixed the zk health-stat test to send the now-required profile_id.

Frontend

  • crypto/keys.ts: generateProfileDek, wrapProfileDek, unwrapProfileDek (thin wrappers over existing wrapDek/unwrapDek — the account DEK serves as the wrap key), plus an in-memory per-profile DEK store with an active-profile concept (getActiveProfileDek()).
  • useProfileStore rewritten: holds profiles[], activeProfileId; loadProfiles fetches the list and unwraps each profile DEK under the account DEK; create/update/delete.
  • All 11 encrypt/decrypt sites in useStore.ts switched from getEncKey() to getActiveProfileDek(). Each load* action passes ?profile_id=<active> so only the active profile's rows come back (otherwise decrypt throws on other-profile ciphertext).
  • ProfileEditor rewritten for the new store (edit active profile's name/kind/relationship, create new profile, delete). New ProfileSwitcher in the Dashboard AppBar.
  • MedicationManager / AppointmentsManager use the active profile id from the store instead of the hardcoded profile_<user_id>.
  • Tests: 2 new crypto tests for per-profile DEK isolation (two profiles get distinct DEKs; data under one can't be decrypted by the other's key); updated the store + component tests for the active-profile-DEK model.

Verification

  • Backend: cargo build + cargo clippy --all-targets --all-features + cargo fmt --check all clean. Tests compile.
  • Frontend: tsc --noEmit clean; 30/30 tests pass.

⚠️ Caveat: integration tests not run locally

My sandbox has no local MongoDB (and no running Docker daemon), so I could not execute the backend integration tests here — they were silently skipping. I compiled everything, ran clippy/fmt, reasoned through each test payload carefully, and fixed the one predictable break (the zk health-stat test needed the now-required profile_id). CI has Mongo (PR #8 fixed the service-container port collision), so the integration tests — including the new profile_tests.rs — will run for real here. If any fail, I'll fix them immediately.

Out of scope (follow-ups)

  • Phase B: actually using the X25519 keypair + per-profile DEKs to wrap to a recipient — profile_shares collection, recipient endpoints.
  • Phase C (hard revoke / re-key), Phase D (graduation / ownership transfer).
  • Dead AccessClaims cleanup (#5).

Refs #3. Does not close it (Phases B/C/D remain).

## Summary Phase A2 of the multi-person sharing ADR ([`docs/adr/multi-person-sharing.md`](../blob/main/docs/adr/multi-person-sharing.md)). Implements the **3-tier key model**: each account owns multiple profiles (a person or pet — a "subject of care"), and each profile has its own random AES-256-GCM DEK. All health data is now encrypted under the **active profile's** DEK, not the account-wide DEK. The account DEK wraps each profile DEK; the server stores only opaque wrapped blobs. This is the largest piece of the ADR. Per the ADR: **DB wipe, no migration** (no real user data). It unblocks Phase B (sharing) — there is now a per-profile key to wrap to a recipient's X25519 public key. ## Decisions (from clarification) - One PR (backend + frontend together) - Health stats gain `profile_id` (were previously unbound) - `/api/profiles/me` removed — no compat shim; the client uses `GET /api/profiles` (list) + active-profile selection ## Backend - **`models/profile.rs`**: Profile gains `owner_account_id`, `kind` (human/pet), `relationship`, `wrapped_profile_dek` + iv. `ProfileRepository` gains `find_all_by_owner`, `find_by_profile_id_owned`, `update_profile`, `delete_profile` — all ownership-scoped (queries filter on `ownerAccountId`). - **`handlers/profile.rs`**: full multi-profile CRUD — `GET /api/profiles` (list), `POST /api/profiles` (create), `GET/PUT/DELETE /api/profiles/:id`. Removed `/api/profiles/me`. - **`handlers/auth.rs`**: register accepts `default_profile_*` fields and auto-creates the self profile when the client provides a wrapped profile DEK. - **Health stats + appointments**: both gain `profile_id` and `?profile_id=` filtering (health stats previously had *no* profile binding at all). - **Rename**: `handlers/users.rs` `get_profile`/`update_profile` (the `/api/users/me` handlers) → `get_account`/`update_account`, to resolve a name collision with the new profile handlers. They were misnamed — they operate on the user account, not a profile. - **New `profile_tests.rs`**: multi-profile CRUD, ownership isolation (user B cannot read/update/delete user A's profile), and register-with-default-profile. Fixed the zk health-stat test to send the now-required `profile_id`. ## Frontend - **`crypto/keys.ts`**: `generateProfileDek`, `wrapProfileDek`, `unwrapProfileDek` (thin wrappers over existing `wrapDek`/`unwrapDek` — the account DEK serves as the wrap key), plus an in-memory per-profile DEK store with an active-profile concept (`getActiveProfileDek()`). - **`useProfileStore`** rewritten: holds `profiles[]`, `activeProfileId`; `loadProfiles` fetches the list and unwraps each profile DEK under the account DEK; create/update/delete. - **All 11 encrypt/decrypt sites** in `useStore.ts` switched from `getEncKey()` to `getActiveProfileDek()`. Each `load*` action passes `?profile_id=<active>` so only the active profile's rows come back (otherwise decrypt throws on other-profile ciphertext). - **`ProfileEditor`** rewritten for the new store (edit active profile's name/kind/relationship, create new profile, delete). New **`ProfileSwitcher`** in the Dashboard AppBar. - **`MedicationManager` / `AppointmentsManager`** use the active profile id from the store instead of the hardcoded `profile_<user_id>`. - **Tests**: 2 new crypto tests for per-profile DEK isolation (two profiles get distinct DEKs; data under one can't be decrypted by the other's key); updated the store + component tests for the active-profile-DEK model. ## Verification - **Backend**: `cargo build` + `cargo clippy --all-targets --all-features` + `cargo fmt --check` all clean. Tests compile. - **Frontend**: `tsc --noEmit` clean; **30/30 tests pass**. ## ⚠️ Caveat: integration tests not run locally My sandbox has **no local MongoDB** (and no running Docker daemon), so I could not execute the backend integration tests here — they were silently skipping. I compiled everything, ran clippy/fmt, reasoned through each test payload carefully, and fixed the one predictable break (the zk health-stat test needed the now-required `profile_id`). **CI has Mongo** (PR #8 fixed the service-container port collision), so the integration tests — including the new `profile_tests.rs` — will run for real here. If any fail, I'll fix them immediately. ## Out of scope (follow-ups) - **Phase B**: actually *using* the X25519 keypair + per-profile DEKs to wrap to a recipient — `profile_shares` collection, recipient endpoints. - **Phase C** (hard revoke / re-key), **Phase D** (graduation / ownership transfer). - Dead `AccessClaims` cleanup (#5). Refs #3. Does not close it (Phases B/C/D remain).
alvaro added 1 commit 2026-07-19 02:45:45 +00:00
feat: per-profile DEKs + multi-profile (Phase A2, #3)
Some checks failed
Lint and Build / format (pull_request) Successful in 39s
Lint and Build / clippy (pull_request) Successful in 1m40s
Lint and Build / build (pull_request) Successful in 3m45s
Lint and Build / test (pull_request) Failing after 2m55s
eb2c2aa546
Implements the 3-tier key model from the multi-person sharing ADR:
each account owns multiple profiles (a person or pet — a 'subject of
care'), and each profile has its own random AES-256-GCM DEK. All
health data is now encrypted under the active profile's DEK, not the
account-wide DEK. The account DEK wraps each profile DEK; the server
stores only opaque wrapped blobs.

Per the ADR: DB wipe, no migration (no real user data). This unblocks
Phase B (sharing) — there is now a per-profile key to wrap to a
recipient's X25519 public key.

Backend:
- Profile model: owner_account_id, kind (human/pet), relationship,
  wrapped_profile_dek + iv. ProfileRepository gains find_all_by_owner,
  find_by_profile_id_owned, update_profile, delete_profile — all
  ownership-scoped.
- Profile handlers: GET/POST /api/profiles, GET/PUT/DELETE
  /api/profiles/:id. Removed /api/profiles/me. Renamed users.rs
  get_profile/update_profile (the /api/users/me handlers) to
  get_account/update_account to resolve a name collision.
- Register accepts default_profile_* fields and auto-creates the self
  profile when the client provides a wrapped profile DEK.
- HealthStatistic + Appointment gain profile_id and ?profile_id=
  filtering (health stats previously had no profile binding).
- New profile_tests.rs: multi-profile CRUD + ownership isolation +
  register-with-default-profile. Fixed the zk health-stat test to
  send the now-required profile_id.

Frontend:
- crypto/keys.ts: generateProfileDek, wrapProfileDek, unwrapProfileDek
  + in-memory per-profile DEK store with an active-profile concept.
- useProfileStore rewritten: holds profiles[], activeProfileId;
  loadProfiles unwraps each profile DEK; create/update/delete. All 11
  encrypt/decrypt sites switched from getEncKey() to
  getActiveProfileDek(). load actions pass ?profile_id= so only the
  active profile's rows come back.
- ProfileEditor rewritten for the new store (edit active profile,
  create/delete). New ProfileSwitcher in the Dashboard AppBar.
- MedicationManager / AppointmentsManager use the active profile id
  instead of the hardcoded profile_<user_id>.
- 2 new crypto tests for per-profile DEK isolation; updated store +
  component tests for the active-profile-DEK model.

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

Closes nothing yet (Phase B/C/D remain). Refs #3.
alvaro added 1 commit 2026-07-19 05:29:10 +00:00
fix(auth): wrong password returns Ok(false), not Err
All checks were successful
Lint and Build / format (pull_request) Successful in 37s
Lint and Build / clippy (pull_request) Successful in 1m40s
Lint and Build / build (pull_request) Successful in 3m43s
Lint and Build / test (pull_request) Successful in 3m21s
706df11f15
verify_password() was mapping every password-hash failure — including a
plain wrong password (password_hash::Error::Password) — to Err, which
propagated to the login handler's Err arm and returned 500
'authentication error' instead of 401 'invalid credentials'.

All three callers (login, change_password, recover_password) already
match on Ok(true)/Ok(false)/Err and expect Ok(false) for a mismatch;
the function just wasn't honoring that contract. Map
Error::Password -> Ok(false) and propagate only genuine failures as Err.

This was a latent bug: CI's auth tests never ran before because the
Mongo service container couldn't schedule (port collision, fixed in
#8). Now that CI runs them for real, login_with_wrong_password_is_rejected
exposed it. Behavior improves on all three call sites — a wrong recovery
phrase now also correctly 401s instead of 500ing.
Author
Owner

Test failure diagnosed + fixed (commit 706df11)

The failing login_with_wrong_password_is_rejected test expected 401 for a wrong password but got 500 "authentication error".

Root cause

verify_password() was mapping every password-hash failure — including a plain wrong password (password_hash::Error::Password) — to Err(...). That propagated to the login handler's Err arm, which returns 500. So a wrong password produced a 500 instead of a 401.

All three callers (login, change_password, recover_password) already match on Ok(true) / Ok(false) / Err and expect Ok(false) for a mismatch — the function just wasn't honoring that contract.

Fix

Map Error::Password → Ok(false) and propagate only genuine failures (unparseable hash, malformed input) as Err. Three-line change in backend/src/auth/password.rs.

Why this surfaced now and not before

This is a pre-existing latent bug, not an A2 regression — I didn't touch the login handler or password verification in this PR. It was invisible because CI's auth tests never actually ran: the Mongo service container couldn't schedule (the port collision that PR #8 fixed). Now that Mongo works in CI, the auth tests run for real and exposed it. This is exactly the value of getting CI green.

Side-effect benefit

The recover_password flow had the same latent bug — a wrong recovery phrase was 500ing instead of 401ing. It's now correct too, since it uses the same helper.

Pushed to this branch; CI should re-run.

## Test failure diagnosed + fixed (commit 706df11) The failing `login_with_wrong_password_is_rejected` test expected **401** for a wrong password but got **500 "authentication error"**. ### Root cause `verify_password()` was mapping *every* password-hash failure — including a plain wrong password (`password_hash::Error::Password`) — to `Err(...)`. That propagated to the login handler's `Err` arm, which returns 500. So a wrong password produced a 500 instead of a 401. All three callers (`login`, `change_password`, `recover_password`) already `match` on `Ok(true) / Ok(false) / Err` and **expect `Ok(false)` for a mismatch** — the function just wasn't honoring that contract. ### Fix Map `Error::Password → Ok(false)` and propagate only genuine failures (unparseable hash, malformed input) as `Err`. Three-line change in `backend/src/auth/password.rs`. ### Why this surfaced now and not before This is a **pre-existing latent bug**, not an A2 regression — I didn't touch the login handler or password verification in this PR. It was invisible because CI's auth tests never actually ran: the Mongo service container couldn't schedule (the port collision that PR #8 fixed). Now that Mongo works in CI, the auth tests run for real and exposed it. This is exactly the value of getting CI green. ### Side-effect benefit The `recover_password` flow had the same latent bug — a wrong recovery phrase was 500ing instead of 401ing. It's now correct too, since it uses the same helper. Pushed to this branch; CI should re-run.
alvaro merged commit 7409077355 into main 2026-07-19 09:46:54 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: alvaro/normogen#9
No description provided.