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.
This commit is contained in:
goose 2026-07-19 15:15:39 -03:00
parent 76fd5a8c26
commit bf5aeedbc2
10 changed files with 861 additions and 1 deletions

View file

@ -15,6 +15,7 @@ import {
} from '@mui/material';
import PersonAddIcon from '@mui/icons-material/PersonAdd';
import PersonRemoveIcon from '@mui/icons-material/PersonRemove';
import KeyIcon from '@mui/icons-material/Key';
import { useProfileStore } from '../../store/useStore';
/** Owner-side sharing UI for a single owned profile: lists the recipients the
@ -27,6 +28,7 @@ export const ProfileSharing: FC<{ profileId: string }> = ({ profileId }) => {
loadProfileShares,
shareProfile,
revokeShare,
rekeyProfile,
error,
clearError,
} = useProfileStore();
@ -66,6 +68,36 @@ export const ProfileSharing: FC<{ profileId: string }> = ({ profileId }) => {
}
};
const handleHardRevoke = async () => {
// Hard revoke = rotate the profile DEK. All currently-listed recipients
// are KEPT (re-wrapped to the new DEK); the owner removes specific
// recipients via the per-row revoke button above first if they want them
// gone at the key level. Expensive (re-encrypts all the profile's data),
// but resume-safe — partial runs can be retried.
const ok = confirm(
'Rotate this profile\'s encryption key?\n\n' +
'This re-encrypts ALL the profile\'s data (medications, appointments, ' +
'health stats) under a new key — it may take a moment. All currently-' +
'listed recipients keep access (re-wrapped to the new key). Anyone who ' +
'previously had access and was removed, or any cached copy of the old ' +
'key, will stop working.\n\nUse this if you suspect a key was compromised.',
);
if (!ok) return;
setBusy(true);
setLocalError('');
try {
const keepIds = shares.map((s) => s.recipient_user_id);
await rekeyProfile(profileId, keepIds);
} catch (e: any) {
setLocalError(
(e?.message || 'Re-key failed') +
' — you can retry; rows already re-encrypted are skipped.',
);
} finally {
setBusy(false);
}
};
return (
<Box>
<Typography variant="subtitle1" sx={{ mb: 1 }}>
@ -133,6 +165,23 @@ export const ProfileSharing: FC<{ profileId: string }> = ({ profileId }) => {
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
The recipient must have a Normogen account. They'll see this profile in their switcher.
</Typography>
<Box sx={{ mt: 3, pt: 2, borderTop: 1, borderColor: 'divider' }}>
<Button
size="small"
color="warning"
variant="outlined"
startIcon={<KeyIcon />}
disabled={busy}
onClick={handleHardRevoke}
>
{busy ? <CircularProgress size={20} /> : 'Rotate encryption key (hard revoke)'}
</Button>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Generates a new key and re-encrypts this profile's data. Use if a key
may have been compromised. Resume-safe you can retry on failure.
</Typography>
</Box>
</Box>
);
};

View file

@ -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[]> {

View file

@ -41,6 +41,9 @@ import {
AdherenceStats,
Profile,
ProfileShareListing,
MedicationWireResponse,
AppointmentWireResponse,
HealthStatWireResponse,
Appointment,
CreateAppointmentRequest,
UpdateAppointmentRequest,
@ -153,6 +156,14 @@ interface ProfileState {
/** Shares the current user has created for a profile (for the owner UI). */
profileShares: Record<string, ProfileShareListing[]>;
loadProfileShares: (profileId: string) => Promise<void>;
/** Owner: hard revoke / rotate the profile DEK. Re-encrypts all the
* profile's data under a fresh DEK (client-side), then commits the
* rotation server-side. Recipients not in `keepRecipientUserIds` are
* hard-revoked (lose access at the key level). Expensive: O(data size). */
rekeyProfile: (
profileId: string,
keepRecipientUserIds: string[],
) => Promise<void>;
clearError: () => void;
}
@ -1121,6 +1132,127 @@ export const useProfileStore = create<ProfileState>()(
}
},
rekeyProfile: async (profileId, keepRecipientUserIds) => {
// Phase C hard revoke. Re-encrypts all the profile's data client-side
// under a fresh DEK, then commits the rotation. Resume-safe per-row:
// if a row was already re-encrypted on a prior partial run, decrypt-
// with-old fails and we try decrypt-with-new; if that succeeds, the
// row is already on the new DEK and we skip it.
const accountDek = getEncKey();
const oldDek = getProfileDek(profileId);
const myIdentityPrivate = getIdentityPrivate();
if (!accountDek || !oldDek) {
set({ error: 'Unlock the profile first (no account/profile key)' });
throw new Error('No keys');
}
set({ isLoading: true, error: null });
try {
const newDek = await generateProfileDek();
// 3a. Re-encrypt every data row under the new DEK.
// medications + appointments: POST /:id with the new blob.
// health-stats: PUT /:id with the new blob.
const reencryptRow = async (
getBlob: () => Promise<{ data: string; iv: string } | null>,
reencrypt: (newBlob: { data: string; iv: string }) => Promise<void>,
) => {
const blob = await getBlob();
if (!blob) return;
let plaintext: string;
try {
plaintext = await decryptRaw(blob, oldDek);
} catch {
// Maybe already re-encrypted on a prior partial run — verify with
// the new DEK, and skip if so.
try {
await decryptRaw(blob, newDek);
return; // already on newDek
} catch {
throw new Error('row could not be decrypted with old or new DEK');
}
}
const newBlob = await encryptRaw(plaintext, newDek);
await reencrypt(newBlob);
};
const meds: MedicationWireResponse[] = await apiService.getMedications(profileId);
for (const m of meds) {
await reencryptRow(
async () => (m.encrypted_data?.data ? m.encrypted_data : null),
async (newBlob) => {
await apiService.updateMedication(m.medication_id, {
encrypted_data: newBlob,
});
},
);
}
const appts: AppointmentWireResponse[] = await apiService.getAppointments(
undefined,
profileId,
);
for (const a of appts) {
await reencryptRow(
async () => (a.encrypted_data?.data ? a.encrypted_data : null),
async (newBlob) => {
await apiService.updateAppointment(a.appointment_id, { encrypted_data: newBlob });
},
);
}
const stats: HealthStatWireResponse[] = await apiService.getHealthStats(profileId);
for (const s of stats) {
await reencryptRow(
async () => (s.encrypted_data?.data ? s.encrypted_data : null),
async (newBlob) => {
await apiService.updateHealthStat(s.id, { encrypted_data: newBlob });
},
);
}
// 3b. Build a fresh ECDH envelope per kept recipient.
const recipientEnvelopes = [];
if (myIdentityPrivate) {
const shares = get().profileShares[profileId] ?? [];
for (const s of shares) {
if (!keepRecipientUserIds.includes(s.recipient_user_id)) continue;
const { identity_public_key: recipientPub } = await apiService.getUserPublicKey(
s.recipient_email,
);
const env = await wrapProfileDekToRecipient(
newDek,
recipientPub,
myIdentityPrivate,
);
recipientEnvelopes.push({
recipient_user_id: s.recipient_user_id,
ephemeral_public_key: env.ephemeralPublicKey,
wrapped_profile_dek: env.wrappedProfileDek.data,
wrapped_profile_dek_iv: env.wrappedProfileDek.iv,
});
}
}
// 3c. Commit: rotate the owner share (new DEK wrapped under the account
// DEK) + recipient envelopes, hard-delete omitted recipients.
const ownerWrap = await wrapProfileDek(newDek, accountDek);
await apiService.rekeyProfile(profileId, {
new_wrapped_profile_dek: ownerWrap.data,
new_wrapped_profile_dek_iv: ownerWrap.iv,
recipient_envelopes: recipientEnvelopes,
});
// 3d. Swap the in-memory DEK and refresh the share listing.
setProfileDek(profileId, newDek);
await get().loadProfileShares(profileId);
set({ isLoading: false });
} catch (error: any) {
set({
error: error.message || 'Re-key failed (you can retry — already-re-encrypted rows are skipped)',
isLoading: false,
});
throw error;
}
},
clearError: () => set({ error: null }),
})),
);

View file

@ -408,6 +408,29 @@ export interface ProfileShareListing {
active: boolean;
}
// Phase C hard revoke: one fresh ECDH envelope per recipient the owner keeps.
export interface RecipientEnvelope {
recipient_user_id: string;
ephemeral_public_key: string;
wrapped_profile_dek: string;
wrapped_profile_dek_iv: string;
}
// Request body for POST /profiles/:id/rekey (hard revoke / rotate the DEK).
export interface RekeyRequest {
new_wrapped_profile_dek: string;
new_wrapped_profile_dek_iv: string;
recipient_envelopes: RecipientEnvelope[];
}
// Response from POST /profiles/:id/rekey.
export interface RekeyResponse {
profile_id: string;
wrapped_profile_dek: string;
wrapped_profile_dek_iv: string;
retained_recipient_user_ids: string[];
}
// Response from GET /users/public-key?email=...
export interface PublicKeyResponse {
user_id: string;