feat: per-profile DEKs + multi-profile (Phase A2, #3) #9
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/3-phase-a2-per-profile-deks"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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)
profile_id(were previously unbound)/api/profiles/meremoved — no compat shim; the client usesGET /api/profiles(list) + active-profile selectionBackend
models/profile.rs: Profile gainsowner_account_id,kind(human/pet),relationship,wrapped_profile_dek+ iv.ProfileRepositorygainsfind_all_by_owner,find_by_profile_id_owned,update_profile,delete_profile— all ownership-scoped (queries filter onownerAccountId).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 acceptsdefault_profile_*fields and auto-creates the self profile when the client provides a wrapped profile DEK.profile_idand?profile_id=filtering (health stats previously had no profile binding at all).handlers/users.rsget_profile/update_profile(the/api/users/mehandlers) →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.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-requiredprofile_id.Frontend
crypto/keys.ts:generateProfileDek,wrapProfileDek,unwrapProfileDek(thin wrappers over existingwrapDek/unwrapDek— the account DEK serves as the wrap key), plus an in-memory per-profile DEK store with an active-profile concept (getActiveProfileDek()).useProfileStorerewritten: holdsprofiles[],activeProfileId;loadProfilesfetches the list and unwraps each profile DEK under the account DEK; create/update/delete.useStore.tsswitched fromgetEncKey()togetActiveProfileDek(). Eachload*action passes?profile_id=<active>so only the active profile's rows come back (otherwise decrypt throws on other-profile ciphertext).ProfileEditorrewritten for the new store (edit active profile's name/kind/relationship, create new profile, delete). NewProfileSwitcherin the Dashboard AppBar.MedicationManager/AppointmentsManageruse the active profile id from the store instead of the hardcodedprofile_<user_id>.Verification
cargo build+cargo clippy --all-targets --all-features+cargo fmt --checkall clean. Tests compile.tsc --noEmitclean; 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 newprofile_tests.rs— will run for real here. If any fail, I'll fix them immediately.Out of scope (follow-ups)
profile_sharescollection, recipient endpoints.AccessClaimscleanup (#5).Refs #3. Does not close it (Phases B/C/D remain).
Test failure diagnosed + fixed (commit
706df11)The failing
login_with_wrong_password_is_rejectedtest 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) — toErr(...). That propagated to the login handler'sErrarm, which returns 500. So a wrong password produced a 500 instead of a 401.All three callers (
login,change_password,recover_password) alreadymatchonOk(true) / Ok(false) / Errand expectOk(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) asErr. Three-line change inbackend/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_passwordflow 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.