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:
goose 2026-06-28 21:46:10 -03:00
parent 149ce37654
commit 1301473bfe
7 changed files with 420 additions and 134 deletions

View file

@ -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) {