feat: zero-knowledge encryption Phase 1 — server can no longer read user data
Complete the core zero-knowledge property: all user data (medications, appointments, profile names) is now client-encrypted via AES-GCM; the server stores and returns opaque ciphertext and can never decrypt it. Frontend crypto module (Web Crypto API, no deps): - crypto/keys.ts: double-PBKDF2 derivation from the password — an auth secret (base64, sent to the server as the 'password') and an encryption key (AES-GCM CryptoKey, kept in memory only, never transmitted). In-memory key store. - crypto/cipher.ts: AES-GCM encrypt/decrypt + JSON convenience wrappers. Auth split: login/register now derive the auth secret + enc key from the password BEFORE the API call. Only the auth secret (not the raw password) is sent to the server. The server's PBKDF2 stays as-is (it hashes whatever it receives) but can never derive the enc key. Backend — server treats all data blobs as opaque: - Medication: removed MedicationData + flat MedicationResponse; new MedicationResponse echoes metadata + encrypted_data blob. Create/update accept opaque blobs (whole-blob replace). MedicationData struct deleted. - Appointment: same opaque treatment; status moved to a top-level document field so it remains filterable without decryption. AppointmentData struct deleted. - Profile: name is now an opaque encrypted blob (name_data/name_iv). Auto-created profile starts empty; client sets it. - EncryptedFieldWire shared wire type across medication/appointment. Frontend — decrypt-on-read, encrypt-on-write: - Stores derive the enc key on login/register; decrypt wire responses into domain objects on load; encrypt domain data into blobs on create/update. - API client returns wire types (opaque blobs); components consume decrypted domain data (mostly unchanged — the store does the crypto). - Updated store tests for the ZK contract (derive a real key, mock wire responses). - 20 frontend tests pass, build clean. Backend: 21 tests pass (removed MedicationData/appointment-data deser tests; opaque-blob echo tests added), clippy 0 warnings. KNOWN LIMITATIONS (Phase 2): forgotten password = data loss (no recovery wrapping yet). Page reload requires re-entering the password to re-derive the enc key (in-memory only, by design). No data migration (no real data existed).
This commit is contained in:
parent
149ce37654
commit
1301473bfe
7 changed files with 420 additions and 134 deletions
|
|
@ -1,5 +1,13 @@
|
|||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import {
|
||||
deriveAuthAndEncKeys,
|
||||
setEncKey,
|
||||
clearEncKey,
|
||||
getEncKey,
|
||||
encryptJson,
|
||||
decryptJson,
|
||||
} from '../crypto';
|
||||
import {
|
||||
User,
|
||||
Medication,
|
||||
|
|
@ -88,8 +96,10 @@ interface AppointmentState {
|
|||
isLoading: boolean;
|
||||
error: string | null;
|
||||
loadAppointments: (status?: string) => Promise<void>;
|
||||
createAppointment: (data: CreateAppointmentRequest) => Promise<void>;
|
||||
updateAppointment: (id: string, data: UpdateAppointmentRequest) => Promise<void>;
|
||||
// The store receives DOMAIN data (decrypted fields from the UI) and encrypts
|
||||
// it internally before sending to the server.
|
||||
createAppointment: (data: Record<string, unknown>) => Promise<void>;
|
||||
updateAppointment: (id: string, data: Record<string, unknown>) => Promise<void>;
|
||||
deleteAppointment: (id: string) => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
|
@ -108,7 +118,11 @@ export const useAuthStore = create<AuthState>()(
|
|||
login: async (email: string, password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await apiService.login(email, password);
|
||||
// Zero-knowledge: derive auth secret + encryption key from password.
|
||||
const { authSecret, encKey } = await deriveAuthAndEncKeys(password);
|
||||
setEncKey(encKey);
|
||||
// Send the derived auth secret (not the raw password) to the server.
|
||||
const response = await apiService.login(email, authSecret);
|
||||
set({
|
||||
user: {
|
||||
user_id: response.user_id,
|
||||
|
|
@ -121,6 +135,7 @@ export const useAuthStore = create<AuthState>()(
|
|||
isLoading: false,
|
||||
});
|
||||
} catch (error: any) {
|
||||
clearEncKey();
|
||||
set({
|
||||
error: error.message || 'Login failed',
|
||||
isLoading: false,
|
||||
|
|
@ -133,11 +148,12 @@ export const useAuthStore = create<AuthState>()(
|
|||
register: async (username: string, email: string, password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const { authSecret, encKey } = await deriveAuthAndEncKeys(password);
|
||||
setEncKey(encKey);
|
||||
const response = await apiService.register({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
role: 'patient',
|
||||
password: authSecret,
|
||||
});
|
||||
set({
|
||||
user: {
|
||||
|
|
@ -161,6 +177,7 @@ export const useAuthStore = create<AuthState>()(
|
|||
},
|
||||
|
||||
logout: async () => {
|
||||
clearEncKey();
|
||||
await apiService.logout();
|
||||
set({
|
||||
user: null,
|
||||
|
|
@ -215,9 +232,42 @@ export const useMedicationStore = create<MedicationState>()(
|
|||
adherence: {},
|
||||
|
||||
loadMedications: async () => {
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||
return;
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const medications = await apiService.getMedications();
|
||||
const wireMeds = await apiService.getMedications();
|
||||
// Decrypt each opaque blob into domain Medication objects.
|
||||
const medications = await Promise.all(
|
||||
wireMeds.map(async (w) => {
|
||||
const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key);
|
||||
return {
|
||||
id: w.id,
|
||||
medication_id: w.medication_id,
|
||||
user_id: w.user_id,
|
||||
profile_id: w.profile_id,
|
||||
name: (data.name as string) ?? '',
|
||||
dosage: (data.dosage as string) ?? '',
|
||||
frequency: (data.frequency as string) ?? '',
|
||||
route: data.route as string | undefined,
|
||||
reason: data.reason as string | undefined,
|
||||
instructions: data.instructions as string | undefined,
|
||||
side_effects: data.sideEffects as string[] | undefined,
|
||||
prescribed_by: data.prescribedBy as string | undefined,
|
||||
prescribed_date: data.prescribedDate as string | undefined,
|
||||
start_date: data.startDate as string | undefined,
|
||||
end_date: data.endDate as string | undefined,
|
||||
notes: data.notes as string | undefined,
|
||||
tags: data.tags as string[] | undefined,
|
||||
active: w.active,
|
||||
created_at: w.created_at,
|
||||
updated_at: w.updated_at,
|
||||
} as Medication;
|
||||
}),
|
||||
);
|
||||
set({ medications, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
|
|
@ -228,9 +278,42 @@ export const useMedicationStore = create<MedicationState>()(
|
|||
},
|
||||
|
||||
createMedication: async (data: any) => {
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const medication = await apiService.createMedication(data);
|
||||
// Encrypt the domain fields into an opaque blob.
|
||||
const { name, dosage, frequency, route, reason, instructions,
|
||||
side_effects, prescribed_by, prescribed_date, start_date,
|
||||
end_date, notes, tags, ...rest } = data;
|
||||
const encrypted_data = await encryptJson({
|
||||
name, dosage, frequency, route, reason, instructions,
|
||||
sideEffects: side_effects, prescribedBy: prescribed_by,
|
||||
prescribedDate: prescribed_date, startDate: start_date,
|
||||
endDate: end_date, notes, tags,
|
||||
}, key);
|
||||
const wire = await apiService.createMedication({
|
||||
profile_id: data.profile_id,
|
||||
encrypted_data,
|
||||
active: data.active,
|
||||
});
|
||||
// Decrypt the response back into a domain Medication for the store.
|
||||
const decrypted = await decryptJson<Record<string, unknown>>(wire.encrypted_data, key);
|
||||
const medication = {
|
||||
id: wire.id,
|
||||
medication_id: wire.medication_id,
|
||||
user_id: wire.user_id,
|
||||
profile_id: wire.profile_id,
|
||||
name: (decrypted.name as string) ?? '',
|
||||
dosage: (decrypted.dosage as string) ?? '',
|
||||
frequency: (decrypted.frequency as string) ?? '',
|
||||
active: wire.active,
|
||||
created_at: wire.created_at,
|
||||
updated_at: wire.updated_at,
|
||||
} as Medication;
|
||||
set((state) => ({
|
||||
medications: [...state.medications, medication],
|
||||
isLoading: false,
|
||||
|
|
@ -245,12 +328,33 @@ export const useMedicationStore = create<MedicationState>()(
|
|||
},
|
||||
|
||||
updateMedication: async (id: string, data: any) => {
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const updated = await apiService.updateMedication(id, data);
|
||||
// Encrypt the full updated domain record into a new blob.
|
||||
const { name, dosage, frequency, route, reason, instructions,
|
||||
side_effects, prescribed_by, prescribed_date, start_date,
|
||||
end_date, notes, tags } = data;
|
||||
const encrypted_data = await encryptJson({
|
||||
name, dosage, frequency, route, reason, instructions,
|
||||
sideEffects: side_effects, prescribedBy: prescribed_by,
|
||||
prescribedDate: prescribed_date, startDate: start_date,
|
||||
endDate: end_date, notes, tags,
|
||||
}, key);
|
||||
const wire = await apiService.updateMedication(id, {
|
||||
encrypted_data,
|
||||
active: data.active,
|
||||
});
|
||||
// Update the domain medication in the store.
|
||||
set((state) => ({
|
||||
medications: state.medications.map((med) =>
|
||||
med.medication_id === id ? updated : med
|
||||
med.medication_id === id
|
||||
? { ...med, ...data, updated_at: wire.updated_at }
|
||||
: med,
|
||||
),
|
||||
isLoading: false,
|
||||
}));
|
||||
|
|
@ -451,9 +555,30 @@ export const useProfileStore = create<ProfileState>()(
|
|||
error: null,
|
||||
|
||||
loadProfile: async () => {
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||
return;
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const profile = await apiService.getProfile();
|
||||
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 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 });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
|
|
@ -464,9 +589,28 @@ export const useProfileStore = create<ProfileState>()(
|
|||
},
|
||||
|
||||
updateName: async (name) => {
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const profile = await apiService.updateProfileName(name);
|
||||
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 */ }
|
||||
const profile: Profile = {
|
||||
profile_id: wire.profile_id,
|
||||
user_id: wire.user_id,
|
||||
name: decryptedName,
|
||||
role: wire.role,
|
||||
permissions: wire.permissions,
|
||||
created_at: wire.created_at,
|
||||
updated_at: wire.updated_at,
|
||||
};
|
||||
set({ profile, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
|
|
@ -489,9 +633,36 @@ export const useAppointmentStore = create<AppointmentState>()(
|
|||
error: null,
|
||||
|
||||
loadAppointments: async (status) => {
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||
return;
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const appointments = await apiService.getAppointments(status);
|
||||
const wireAppts = await apiService.getAppointments(status);
|
||||
const appointments = await Promise.all(
|
||||
wireAppts.map(async (w) => {
|
||||
const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key);
|
||||
return {
|
||||
id: w.id,
|
||||
appointment_id: w.appointment_id,
|
||||
user_id: w.user_id,
|
||||
profile_id: w.profile_id,
|
||||
title: (data.title as string) ?? '',
|
||||
provider: (data.provider as string) ?? '',
|
||||
appointment_type: (data.appointmentType as string) ?? '',
|
||||
date_time: (data.dateTime as string) ?? '',
|
||||
location: data.location as string | undefined,
|
||||
duration_minutes: data.durationMinutes as number | undefined,
|
||||
reason: data.reason as string | undefined,
|
||||
notes: data.notes as string | undefined,
|
||||
status: w.status,
|
||||
created_at: w.created_at,
|
||||
updated_at: w.updated_at,
|
||||
} as Appointment;
|
||||
}),
|
||||
);
|
||||
set({ appointments, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
|
|
@ -502,11 +673,40 @@ export const useAppointmentStore = create<AppointmentState>()(
|
|||
},
|
||||
|
||||
createAppointment: async (data) => {
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const appt = await apiService.createAppointment(data);
|
||||
const d = data as any;
|
||||
const { title, provider, appointment_type, date_time, location,
|
||||
duration_minutes, reason, notes } = d;
|
||||
const encrypted_data = await encryptJson({
|
||||
title, provider, appointmentType: appointment_type,
|
||||
dateTime: date_time, location, durationMinutes: duration_minutes,
|
||||
reason, notes,
|
||||
}, key);
|
||||
const wire = await apiService.createAppointment({
|
||||
profile_id: d.profile_id,
|
||||
encrypted_data,
|
||||
status: d.status,
|
||||
});
|
||||
// Build the domain Appointment for the store.
|
||||
const appointment: Appointment = {
|
||||
appointment_id: wire.appointment_id,
|
||||
id: wire.id,
|
||||
user_id: wire.user_id,
|
||||
profile_id: wire.profile_id,
|
||||
title, provider, appointment_type, date_time,
|
||||
location, duration_minutes, reason, notes,
|
||||
status: wire.status,
|
||||
created_at: wire.created_at,
|
||||
updated_at: wire.updated_at,
|
||||
};
|
||||
set((state) => ({
|
||||
appointments: [...state.appointments, appt],
|
||||
appointments: [...state.appointments, appointment],
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
|
|
@ -519,11 +719,29 @@ export const useAppointmentStore = create<AppointmentState>()(
|
|||
},
|
||||
|
||||
updateAppointment: async (id, data) => {
|
||||
const key = getEncKey();
|
||||
if (!key) {
|
||||
set({ error: 'No encryption key — cannot encrypt data' });
|
||||
throw new Error('No encryption key');
|
||||
}
|
||||
try {
|
||||
const updated = await apiService.updateAppointment(id, data);
|
||||
const d = data as any;
|
||||
const { title, provider, appointment_type, date_time, location,
|
||||
duration_minutes, reason, notes } = d;
|
||||
const encrypted_data = await encryptJson({
|
||||
title, provider, appointmentType: appointment_type,
|
||||
dateTime: date_time, location, durationMinutes: duration_minutes,
|
||||
reason, notes,
|
||||
}, key);
|
||||
const wire = await apiService.updateAppointment(id, {
|
||||
encrypted_data,
|
||||
status: d.status,
|
||||
});
|
||||
set((state) => ({
|
||||
appointments: state.appointments.map((a) =>
|
||||
a.appointment_id === id ? updated : a,
|
||||
a.appointment_id === id
|
||||
? { ...a, ...data, status: wire.status, updated_at: wire.updated_at }
|
||||
: a,
|
||||
),
|
||||
}));
|
||||
} catch (error: any) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue