feat: per-profile DEKs + multi-profile (Phase A2, #3)
Implements the 3-tier key model from the multi-person sharing ADR: 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. Per the ADR: DB wipe, no migration (no real user data). This unblocks Phase B (sharing) — there is now a per-profile key to wrap to a recipient's X25519 public key. Backend: - Profile model: owner_account_id, kind (human/pet), relationship, wrapped_profile_dek + iv. ProfileRepository gains find_all_by_owner, find_by_profile_id_owned, update_profile, delete_profile — all ownership-scoped. - Profile handlers: GET/POST /api/profiles, GET/PUT/DELETE /api/profiles/:id. Removed /api/profiles/me. Renamed users.rs get_profile/update_profile (the /api/users/me handlers) to get_account/update_account to resolve a name collision. - Register accepts default_profile_* fields and auto-creates the self profile when the client provides a wrapped profile DEK. - HealthStatistic + Appointment gain profile_id and ?profile_id= filtering (health stats previously had no profile binding). - New profile_tests.rs: multi-profile CRUD + ownership isolation + register-with-default-profile. Fixed the zk health-stat test to send the now-required profile_id. Frontend: - crypto/keys.ts: generateProfileDek, wrapProfileDek, unwrapProfileDek + in-memory per-profile DEK store with an active-profile concept. - useProfileStore rewritten: holds profiles[], activeProfileId; loadProfiles unwraps each profile DEK; create/update/delete. All 11 encrypt/decrypt sites switched from getEncKey() to getActiveProfileDek(). load actions pass ?profile_id= so only the active profile's rows come back. - ProfileEditor rewritten for the new store (edit active profile, create/delete). New ProfileSwitcher in the Dashboard AppBar. - MedicationManager / AppointmentsManager use the active profile id instead of the hardcoded profile_<user_id>. - 2 new crypto tests for per-profile DEK isolation; updated store + component tests for the active-profile-DEK model. Verification: backend cargo build/clippy/fmt green, tests compile (integration tests run in CI — Mongo is fixed there). Frontend tsc clean, 30/30 tests pass. Closes nothing yet (Phase B/C/D remain). Refs #3.
This commit is contained in:
parent
9807434c5f
commit
eb2c2aa546
25 changed files with 1322 additions and 206 deletions
|
|
@ -9,11 +9,22 @@ import {
|
|||
generateIdentityKeyPair,
|
||||
wrapIdentityPrivateKey,
|
||||
unwrapIdentityPrivateKey,
|
||||
generateProfileDek,
|
||||
wrapProfileDek,
|
||||
unwrapProfileDek,
|
||||
encrypt as encryptRaw,
|
||||
decrypt as decryptRaw,
|
||||
setEncKey,
|
||||
clearEncKey,
|
||||
clearIdentityPrivate,
|
||||
clearProfileDeks,
|
||||
setIdentityPrivate,
|
||||
getEncKey,
|
||||
getActiveProfileDek,
|
||||
getActiveProfileId,
|
||||
setActiveProfileId,
|
||||
setProfileDek,
|
||||
getProfileDek,
|
||||
encryptJson,
|
||||
decryptJson,
|
||||
type CipherPayload,
|
||||
|
|
@ -106,11 +117,26 @@ interface InteractionState {
|
|||
}
|
||||
|
||||
interface ProfileState {
|
||||
profile: Profile | null;
|
||||
profiles: Profile[];
|
||||
activeProfileId: string | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
loadProfile: () => Promise<void>;
|
||||
updateName: (name: string) => Promise<void>;
|
||||
/** Fetch all owned profiles and unwrap each per-profile DEK under the account
|
||||
* DEK into the in-memory crypto store. Sets the first profile active. */
|
||||
loadProfiles: () => Promise<void>;
|
||||
/** Switch the active profile (whose DEK encrypts/decrypts the UI's data). */
|
||||
setActiveProfile: (profileId: string) => void;
|
||||
createProfile: (input: {
|
||||
name: string;
|
||||
kind?: string;
|
||||
relationship?: string;
|
||||
}) => Promise<void>;
|
||||
updateProfile: (profileId: string, input: {
|
||||
name: string;
|
||||
kind?: string;
|
||||
relationship?: string;
|
||||
}) => Promise<void>;
|
||||
deleteProfile: (profileId: string) => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -225,6 +251,14 @@ export const useAuthStore = create<AuthState>()(
|
|||
const wrappedPrivate = await wrapIdentityPrivateKey(privateKey, setup.dek);
|
||||
setIdentityPrivate(privateKey);
|
||||
|
||||
// Phase A2: generate the default "self" profile's DEK, wrap it
|
||||
// under the account DEK, and encrypt the display name (the chosen
|
||||
// username) under the profile DEK. The server stores both opaque
|
||||
// blobs verbatim and auto-creates the self profile.
|
||||
const defaultProfileDek = await generateProfileDek();
|
||||
const defaultWrapped = await wrapProfileDek(defaultProfileDek, setup.dek);
|
||||
const defaultNameBlob = await encryptRaw(username, defaultProfileDek);
|
||||
|
||||
const response = await apiService.register({
|
||||
username,
|
||||
email,
|
||||
|
|
@ -237,7 +271,16 @@ export const useAuthStore = create<AuthState>()(
|
|||
identity_public_key: publicKey,
|
||||
identity_private_key_wrapped: wrappedPrivate.data,
|
||||
identity_private_key_wrapped_iv: wrappedPrivate.iv,
|
||||
default_profile_name_data: defaultNameBlob.data,
|
||||
default_profile_name_iv: defaultNameBlob.iv,
|
||||
default_wrapped_profile_dek: defaultWrapped.data,
|
||||
default_wrapped_profile_dek_iv: defaultWrapped.iv,
|
||||
});
|
||||
// Seed the in-memory profile-DEK store with the self profile and
|
||||
// make it the active profile.
|
||||
const selfProfileId = `profile_${response.user_id}`;
|
||||
setProfileDek(selfProfileId, defaultProfileDek);
|
||||
setActiveProfileId(selfProfileId);
|
||||
set({
|
||||
user: {
|
||||
user_id: response.user_id,
|
||||
|
|
@ -305,6 +348,7 @@ export const useAuthStore = create<AuthState>()(
|
|||
logout: async () => {
|
||||
clearEncKey();
|
||||
clearIdentityPrivate();
|
||||
clearProfileDeks();
|
||||
await apiService.logout();
|
||||
set({
|
||||
user: null,
|
||||
|
|
@ -369,14 +413,14 @@ export const useMedicationStore = create<MedicationState>()(
|
|||
adherence: {},
|
||||
|
||||
loadMedications: async () => {
|
||||
const key = getEncKey();
|
||||
const key = getActiveProfileDek();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||
return;
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const wireMeds = await apiService.getMedications();
|
||||
const wireMeds = await apiService.getMedications(getActiveProfileId() ?? undefined);
|
||||
// Decrypt each opaque blob into domain Medication objects.
|
||||
const medications = await Promise.all(
|
||||
wireMeds.map(async (w) => {
|
||||
|
|
@ -416,7 +460,7 @@ export const useMedicationStore = create<MedicationState>()(
|
|||
},
|
||||
|
||||
createMedication: async (data: any) => {
|
||||
const key = getEncKey();
|
||||
const key = getActiveProfileDek();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
|
|
@ -466,7 +510,7 @@ export const useMedicationStore = create<MedicationState>()(
|
|||
},
|
||||
|
||||
updateMedication: async (id: string, data: any) => {
|
||||
const key = getEncKey();
|
||||
const key = getActiveProfileDek();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
|
|
@ -565,14 +609,14 @@ export const useHealthStore = create<HealthState>()(
|
|||
error: null,
|
||||
|
||||
loadStats: async () => {
|
||||
const key = getEncKey();
|
||||
const key = getActiveProfileDek();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||
return;
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const wireStats = await apiService.getHealthStats();
|
||||
const wireStats = await apiService.getHealthStats(getActiveProfileId() ?? undefined);
|
||||
const stats = await Promise.all(
|
||||
wireStats.map(async (w) => {
|
||||
const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key);
|
||||
|
|
@ -599,7 +643,7 @@ export const useHealthStore = create<HealthState>()(
|
|||
},
|
||||
|
||||
createStat: async (data: any) => {
|
||||
const key = getEncKey();
|
||||
const key = getActiveProfileDek();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
|
|
@ -607,6 +651,7 @@ export const useHealthStore = create<HealthState>()(
|
|||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const d = data as any;
|
||||
const profileId = getActiveProfileId();
|
||||
const encrypted_data = await encryptJson({
|
||||
stat_type: d.stat_type,
|
||||
value: d.value,
|
||||
|
|
@ -614,6 +659,7 @@ export const useHealthStore = create<HealthState>()(
|
|||
notes: d.notes,
|
||||
}, key);
|
||||
const wire = await apiService.createHealthStat({
|
||||
profile_id: profileId ?? '',
|
||||
encrypted_data,
|
||||
recorded_at: d.measured_at,
|
||||
});
|
||||
|
|
@ -640,7 +686,7 @@ export const useHealthStore = create<HealthState>()(
|
|||
},
|
||||
|
||||
updateStat: async (id: string, data: any) => {
|
||||
const key = getEncKey();
|
||||
const key = getActiveProfileDek();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
|
|
@ -752,71 +798,158 @@ export const useInteractionStore = create<InteractionState>()(
|
|||
}))
|
||||
);
|
||||
|
||||
// Profile Store (Phase 3c)
|
||||
// Profile Store (Phase A2: multi-profile + per-profile DEKs)
|
||||
export const useProfileStore = create<ProfileState>()(
|
||||
devtools((set, get) => ({
|
||||
profile: null,
|
||||
profiles: [],
|
||||
activeProfileId: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
loadProfile: async () => {
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||
loadProfiles: async () => {
|
||||
const accountDek = getEncKey();
|
||||
if (!accountDek) {
|
||||
set({ error: 'No account key — log in to decrypt profiles', isLoading: false });
|
||||
return;
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const wire = await apiService.getProfile();
|
||||
// Decrypt the profile name.
|
||||
let name = '';
|
||||
if (wire.name_data && wire.name_iv) {
|
||||
try {
|
||||
name = await decryptJson<string>({ data: wire.name_data, iv: wire.name_iv }, key);
|
||||
} catch { name = ''; }
|
||||
const wireList = await apiService.listProfiles();
|
||||
// Unwrap each profile's DEK under the account DEK, and decrypt each
|
||||
// profile's display name under that profile's DEK.
|
||||
const profiles: Profile[] = [];
|
||||
for (const wire of wireList) {
|
||||
let profileDek = getProfileDek(wire.profile_id);
|
||||
if (!profileDek) {
|
||||
try {
|
||||
profileDek = await unwrapProfileDek(
|
||||
{ data: wire.wrapped_profile_dek, iv: wire.wrapped_profile_dek_iv },
|
||||
accountDek,
|
||||
);
|
||||
setProfileDek(wire.profile_id, profileDek);
|
||||
} catch {
|
||||
// Could not unwrap this profile's DEK — skip it; the UI will
|
||||
// show the profiles it could decrypt.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let name = '';
|
||||
if (wire.name_data && wire.name_iv && profileDek) {
|
||||
try {
|
||||
name = await decryptRaw({ data: wire.name_data, iv: wire.name_iv }, profileDek);
|
||||
} catch { name = ''; }
|
||||
}
|
||||
profiles.push({
|
||||
profile_id: wire.profile_id,
|
||||
owner_account_id: wire.owner_account_id,
|
||||
name,
|
||||
kind: wire.kind,
|
||||
relationship: wire.relationship,
|
||||
role: wire.role,
|
||||
permissions: wire.permissions,
|
||||
created_at: wire.created_at,
|
||||
updated_at: wire.updated_at,
|
||||
});
|
||||
}
|
||||
const profile: Profile = {
|
||||
profile_id: wire.profile_id,
|
||||
user_id: wire.user_id,
|
||||
name,
|
||||
role: wire.role,
|
||||
permissions: wire.permissions,
|
||||
created_at: wire.created_at,
|
||||
updated_at: wire.updated_at,
|
||||
};
|
||||
set({ profile, isLoading: false });
|
||||
// Set the first profile active if none is selected (or if the active
|
||||
// one is no longer present).
|
||||
const currentActive = getActiveProfileId();
|
||||
const stillOwned = profiles.some((p) => p.profile_id === currentActive);
|
||||
const newActive = stillOwned ? currentActive : (profiles[0]?.profile_id ?? null);
|
||||
if (newActive) setActiveProfileId(newActive);
|
||||
set({ profiles, activeProfileId: newActive, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.message || 'Failed to load profile',
|
||||
error: error.message || 'Failed to load profiles',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateName: async (name) => {
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
setActiveProfile: (profileId) => {
|
||||
setActiveProfileId(profileId);
|
||||
set({ activeProfileId: profileId });
|
||||
},
|
||||
|
||||
createProfile: async ({ name, kind = 'human', relationship = '' }) => {
|
||||
const accountDek = getEncKey();
|
||||
if (!accountDek) {
|
||||
set({ error: 'No account key — cannot create profile' });
|
||||
throw new Error('No account key');
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const blob = await encryptJson(name, key);
|
||||
const wire = await apiService.updateProfileName(blob.data, blob.iv);
|
||||
let decryptedName = name;
|
||||
try {
|
||||
decryptedName = await decryptJson<string>({ data: wire.name_data, iv: wire.name_iv }, key);
|
||||
} catch { /* keep the input name */ }
|
||||
// Generate a fresh profile DEK, wrap it under the account DEK, and
|
||||
// encrypt the display name under the new profile DEK.
|
||||
const profileDek = await generateProfileDek();
|
||||
const wrapped = await wrapProfileDek(profileDek, accountDek);
|
||||
const nameBlob = await encryptRaw(name, profileDek);
|
||||
const wire = await apiService.createProfile({
|
||||
kind,
|
||||
relationship,
|
||||
name_data: nameBlob.data,
|
||||
name_iv: nameBlob.iv,
|
||||
wrapped_profile_dek: wrapped.data,
|
||||
wrapped_profile_dek_iv: wrapped.iv,
|
||||
});
|
||||
// Cache the DEK in memory (we already have it) and set this profile
|
||||
// active — the user just created it.
|
||||
setProfileDek(wire.profile_id, profileDek);
|
||||
setActiveProfileId(wire.profile_id);
|
||||
const profile: Profile = {
|
||||
profile_id: wire.profile_id,
|
||||
user_id: wire.user_id,
|
||||
name: decryptedName,
|
||||
owner_account_id: wire.owner_account_id,
|
||||
name,
|
||||
kind: wire.kind,
|
||||
relationship: wire.relationship,
|
||||
role: wire.role,
|
||||
permissions: wire.permissions,
|
||||
created_at: wire.created_at,
|
||||
updated_at: wire.updated_at,
|
||||
};
|
||||
set({ profile, isLoading: false });
|
||||
set((state) => ({
|
||||
profiles: [...state.profiles, profile],
|
||||
activeProfileId: wire.profile_id,
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.message || 'Failed to create profile',
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateProfile: async (profileId, { name, kind, relationship }) => {
|
||||
const profileDek = getProfileDek(profileId);
|
||||
if (!profileDek) {
|
||||
set({ error: 'No profile key — switch to or unlock this profile first' });
|
||||
throw new Error('No profile key');
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const nameBlob = await encryptRaw(name, profileDek);
|
||||
const wire = await apiService.updateProfile(profileId, {
|
||||
name_data: nameBlob.data,
|
||||
name_iv: nameBlob.iv,
|
||||
kind: kind ?? 'human',
|
||||
relationship: relationship ?? '',
|
||||
});
|
||||
set((state) => ({
|
||||
profiles: state.profiles.map((p) =>
|
||||
p.profile_id === profileId
|
||||
? {
|
||||
...p,
|
||||
name,
|
||||
kind: wire.kind,
|
||||
relationship: wire.relationship,
|
||||
updated_at: wire.updated_at,
|
||||
}
|
||||
: p,
|
||||
),
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.message || 'Failed to update profile',
|
||||
|
|
@ -826,6 +959,27 @@ export const useProfileStore = create<ProfileState>()(
|
|||
}
|
||||
},
|
||||
|
||||
deleteProfile: async (profileId) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await apiService.deleteProfile(profileId);
|
||||
const remaining = get().profiles.filter((p) => p.profile_id !== profileId);
|
||||
// If we deleted the active profile, fall back to the first remaining.
|
||||
let newActive = getActiveProfileId();
|
||||
if (newActive === profileId) {
|
||||
newActive = remaining[0]?.profile_id ?? null;
|
||||
if (newActive) setActiveProfileId(newActive);
|
||||
}
|
||||
set({ profiles: remaining, activeProfileId: newActive, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.message || 'Failed to delete profile',
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
})),
|
||||
);
|
||||
|
|
@ -838,14 +992,14 @@ export const useAppointmentStore = create<AppointmentState>()(
|
|||
error: null,
|
||||
|
||||
loadAppointments: async (status) => {
|
||||
const key = getEncKey();
|
||||
const key = getActiveProfileDek();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||
return;
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const wireAppts = await apiService.getAppointments(status);
|
||||
const wireAppts = await apiService.getAppointments(status, getActiveProfileId() ?? undefined);
|
||||
const appointments = await Promise.all(
|
||||
wireAppts.map(async (w) => {
|
||||
const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key);
|
||||
|
|
@ -878,7 +1032,7 @@ export const useAppointmentStore = create<AppointmentState>()(
|
|||
},
|
||||
|
||||
createAppointment: async (data) => {
|
||||
const key = getEncKey();
|
||||
const key = getActiveProfileDek();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
|
|
@ -924,7 +1078,7 @@ export const useAppointmentStore = create<AppointmentState>()(
|
|||
},
|
||||
|
||||
updateAppointment: async (id, data) => {
|
||||
const key = getEncKey();
|
||||
const key = getActiveProfileDek();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue