feat: hard revoke / re-key (Phase C, #3)
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.
This commit is contained in:
parent
76fd5a8c26
commit
bf5aeedbc2
10 changed files with 861 additions and 1 deletions
|
|
@ -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[]> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue