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

2 commits

Author SHA1 Message Date
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