feat: hard revoke / re-key (Phase C, #3) #15

Merged
alvaro merged 2 commits from feat/3-phase-c-rekey into main 2026-07-20 01:32:55 +00:00
Owner

Summary

Phase C of the multi-person sharing ADR (§4 Revocation). 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.

Decisions (from clarification): full client-side re-key (rotate the DEK AND re-encrypt data); best-effort + resumable retry (no server-side write lock).

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 — use POST /shares for that), rotates the owner share, upserts recipient envelopes with fresh ephemeral ECDH keys, hard-deletes omitted recipients. Returns the surviving recipient list.
  • rekey_tests.rs: owner-share rotation; hard-revoke from the recipient's view (omitted recipient's shared-with-me and gate both 404); envelope-for-recipient-with-no-share rejected (400); non-owner rejected (404); rekey-with-no-envelopes revokes everyone.

Frontend

  • useProfileStore.rekeyProfile(profileId, keepRecipientUserIds): fetches all profile data (meds + appointments + health-stats), re-encrypts each row under a new DEK, builds fresh ECDH envelopes per kept recipient, commits via POST /rekey, swaps the in-memory DEK.
  • Resume-safe: if a row was already re-encrypted on a prior partial run, decrypt-with-old fails → the helper tries decrypt-with-new → if that succeeds, the row is skipped (already on the new DEK). The UI offers retry on failure.
  • ProfileSharing: "Rotate encryption key (hard revoke)" action with a strong confirmation dialog. All currently-listed recipients are kept by default; the owner removes specific ones via the existing per-row revoke button first if they want them gone at the key level.

Verification

  • Backend: cargo build + clippy --all-targets --all-features -D warnings + fmt --check all clean. Tests compile.
  • Frontend: tsc --noEmit clean; 31/31 tests pass.
  • ⚠️ Same Mongo caveat — integration tests run in CI, not locally; I'll fix any failures.

Out of scope

  • Admin-initiated rekey (ADR §5c reserves the permission; admin shares aren't creatable in the UI yet — Phase D). Handler is owner-only for now.
  • Phase D (graduation / ownership transfer) — the last piece of the ADR.
  • Server-side write lock (decided against — best-effort + resumable retry).

Refs #3.

## Summary Phase C of the multi-person sharing ADR (§4 Revocation). 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. **Decisions (from clarification):** full client-side re-key (rotate the DEK AND re-encrypt data); best-effort + resumable retry (no server-side write lock). ## 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 — use `POST /shares` for that), rotates the owner share, upserts recipient envelopes with fresh ephemeral ECDH keys, hard-deletes omitted recipients. Returns the surviving recipient list. - **`rekey_tests.rs`**: owner-share rotation; hard-revoke from the recipient's view (omitted recipient's `shared-with-me` and gate both 404); envelope-for-recipient-with-no-share rejected (400); non-owner rejected (404); rekey-with-no-envelopes revokes everyone. ## Frontend - **`useProfileStore.rekeyProfile(profileId, keepRecipientUserIds)`**: fetches all profile data (meds + appointments + health-stats), re-encrypts each row under a new DEK, builds fresh ECDH envelopes per kept recipient, commits via `POST /rekey`, swaps the in-memory DEK. - **Resume-safe**: if a row was already re-encrypted on a prior partial run, decrypt-with-old fails → the helper tries decrypt-with-new → if that succeeds, the row is skipped (already on the new DEK). The UI offers retry on failure. - **`ProfileSharing`**: "Rotate encryption key (hard revoke)" action with a strong confirmation dialog. All currently-listed recipients are kept by default; the owner removes specific ones via the existing per-row revoke button *first* if they want them gone at the key level. ## Verification - Backend: `cargo build` + `clippy --all-targets --all-features -D warnings` + `fmt --check` all clean. Tests compile. - Frontend: `tsc --noEmit` clean; **31/31 tests pass**. - ⚠️ Same Mongo caveat — integration tests run in CI, not locally; I'll fix any failures. ## Out of scope - **Admin-initiated rekey** (ADR §5c reserves the permission; admin shares aren't creatable in the UI yet — Phase D). Handler is owner-only for now. - **Phase D (graduation / ownership transfer)** — the last piece of the ADR. - Server-side write lock (decided against — best-effort + resumable retry). Refs #3.
alvaro added 1 commit 2026-07-19 18:16:13 +00:00
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
bf5aeedbc2
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.
alvaro added 1 commit 2026-07-20 01:13:54 +00:00
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
6d6af67f2f
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).
alvaro merged commit dabbea2483 into main 2026-07-20 01:32:55 +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#15
No description provided.