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

@ -25,22 +25,32 @@ import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useAppointmentStore, useAuthStore } from '../../store/useStore'; import { useAppointmentStore, useAuthStore } from '../../store/useStore';
import type { import type { Appointment } from '../../types/api';
Appointment,
CreateAppointmentRequest,
UpdateAppointmentRequest,
} from '../../types/api';
const APPT_TYPES = ['in-person', 'telehealth', 'lab', 'test', 'other']; const APPT_TYPES = ['in-person', 'telehealth', 'lab', 'test', 'other'];
const STATUSES = ['upcoming', 'completed', 'cancelled']; const STATUSES = ['upcoming', 'completed', 'cancelled'];
// Domain form type (decrypted fields the UI collects; the store encrypts them).
interface ApptFormData {
title: string;
provider: string;
appointment_type: string;
date_time: string;
location?: string;
duration_minutes?: number;
reason?: string;
notes?: string;
status?: string;
profile_id?: string;
}
const statusColor = (status: string): 'success' | 'default' | 'error' => { const statusColor = (status: string): 'success' | 'default' | 'error' => {
if (status === 'upcoming') return 'success'; if (status === 'upcoming') return 'success';
if (status === 'cancelled') return 'error'; if (status === 'cancelled') return 'error';
return 'default'; return 'default';
}; };
const emptyCreate: CreateAppointmentRequest = { const emptyCreate: ApptFormData = {
title: '', title: '',
provider: '', provider: '',
appointment_type: 'in-person', appointment_type: 'in-person',
@ -67,9 +77,9 @@ export const AppointmentsManager: FC = () => {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState<CreateAppointmentRequest>(emptyCreate); const [createForm, setCreateForm] = useState<ApptFormData>(emptyCreate);
const [editTarget, setEditTarget] = useState<Appointment | null>(null); const [editTarget, setEditTarget] = useState<Appointment | null>(null);
const [editForm, setEditForm] = useState<UpdateAppointmentRequest>({}); const [editForm, setEditForm] = useState<Partial<ApptFormData>>({});
const [deleteTarget, setDeleteTarget] = useState<Appointment | null>(null); const [deleteTarget, setDeleteTarget] = useState<Appointment | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -88,7 +98,7 @@ export const AppointmentsManager: FC = () => {
const submitCreate = async () => { const submitCreate = async () => {
setSaving(true); setSaving(true);
try { try {
await createAppointment(createForm); await createAppointment(createForm as unknown as Record<string, unknown>);
setCreateOpen(false); setCreateOpen(false);
} catch { } catch {
/* error surfaced via store */ /* error surfaced via store */
@ -116,7 +126,7 @@ export const AppointmentsManager: FC = () => {
if (!editTarget?.appointment_id) return; if (!editTarget?.appointment_id) return;
setSaving(true); setSaving(true);
try { try {
await updateAppointment(editTarget.appointment_id, editForm); await updateAppointment(editTarget.appointment_id, editForm as unknown as Record<string, unknown>);
setEditTarget(null); setEditTarget(null);
} catch { } catch {
/* error surfaced via store */ /* error surfaced via store */

View file

@ -25,16 +25,23 @@ import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import { useMedicationStore, useAuthStore } from '../../store/useStore'; import { useMedicationStore, useAuthStore } from '../../store/useStore';
import type { import type { Medication } from '../../types/api';
Medication,
CreateMedicationRequest,
UpdateMedicationRequest,
} from '../../types/api';
import { DoseLogger } from './DoseLogger'; import { DoseLogger } from './DoseLogger';
const ROUTES = ['oral', 'topical', 'injection', 'inhalation', 'other'] as const; const ROUTES = ['oral', 'topical', 'injection', 'inhalation', 'other'] as const;
const emptyCreate: CreateMedicationRequest = { // Domain form types (decrypted fields the UI collects; the store encrypts them).
interface MedFormData {
name: string;
dosage: string;
frequency: string;
route: string;
instructions?: string;
profile_id?: string;
active?: boolean;
}
const emptyCreate: MedFormData = {
name: '', name: '',
dosage: '', dosage: '',
frequency: '', frequency: '',
@ -56,9 +63,9 @@ export const MedicationManager: FC = () => {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState<CreateMedicationRequest>(emptyCreate); const [createForm, setCreateForm] = useState<MedFormData>(emptyCreate);
const [editTarget, setEditTarget] = useState<Medication | null>(null); const [editTarget, setEditTarget] = useState<Medication | null>(null);
const [editForm, setEditForm] = useState<UpdateMedicationRequest>({}); const [editForm, setEditForm] = useState<Partial<MedFormData>>({});
const [deleteTarget, setDeleteTarget] = useState<Medication | null>(null); const [deleteTarget, setDeleteTarget] = useState<Medication | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -238,7 +245,7 @@ export const MedicationManager: FC = () => {
fullWidth fullWidth
value={createForm.route} value={createForm.route}
onChange={(e) => onChange={(e) =>
setCreateForm({ ...createForm, route: e.target.value as CreateMedicationRequest['route'] }) setCreateForm({ ...createForm, route: e.target.value })
} }
> >
{ROUTES.map((r) => ( {ROUTES.map((r) => (

View file

@ -40,9 +40,9 @@ export async function encrypt(
): Promise<CipherPayload> { ): Promise<CipherPayload> {
const iv = randomIv(); const iv = randomIv();
const ciphertext = await crypto.subtle.encrypt( const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, { name: 'AES-GCM', iv: iv as BufferSource },
key, key,
encoder.encode(plaintext), encoder.encode(plaintext) as BufferSource,
); );
return { return {
data: b64.encode(new Uint8Array(ciphertext)), data: b64.encode(new Uint8Array(ciphertext)),
@ -57,9 +57,9 @@ export async function decrypt(
): Promise<string> { ): Promise<string> {
const iv = b64.decode(payload.iv); const iv = b64.decode(payload.iv);
const plaintext = await crypto.subtle.decrypt( const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv }, { name: 'AES-GCM', iv: iv as BufferSource },
key, key,
b64.decode(payload.data), b64.decode(payload.data) as BufferSource,
); );
return decoder.decode(plaintext); return decoder.decode(plaintext);
} }

View file

@ -17,8 +17,10 @@ import {
DoseLog, DoseLog,
LogDoseRequest, LogDoseRequest,
AdherenceStats, AdherenceStats,
Profile, MedicationWireResponse,
Appointment, AppointmentWireResponse,
ProfileWireResponse,
EncryptedFieldWire,
CreateAppointmentRequest, CreateAppointmentRequest,
UpdateAppointmentRequest, UpdateAppointmentRequest,
} from '../types/api'; } from '../types/api';
@ -213,47 +215,48 @@ class ApiService {
return response.data; return response.data;
} }
// ---- Profile (Phase 3c) ---- // ---- Profile (zero-knowledge: opaque encrypted name) ----
async getProfile(): Promise<Profile> { async getProfile(): Promise<ProfileWireResponse> {
const response = await this.client.get<Profile>('/profiles/me'); const response = await this.client.get<ProfileWireResponse>('/profiles/me');
return response.data; return response.data;
} }
async updateProfileName(name: string): Promise<Profile> { async updateProfileName(nameData: string, nameIv: string): Promise<ProfileWireResponse> {
const response = await this.client.put<Profile>('/profiles/me', { name }); const response = await this.client.put<ProfileWireResponse>('/profiles/me', {
name_data: nameData,
name_iv: nameIv,
});
return response.data; return response.data;
} }
// ---- Medications ---- // ---- Medications (zero-knowledge: opaque encrypted blobs) ----
async getMedications(): Promise<Medication[]> { async getMedications(): Promise<MedicationWireResponse[]> {
const response = await this.client.get<Medication[]>('/medications'); const response = await this.client.get<MedicationWireResponse[]>('/medications');
return response.data; return response.data;
} }
async getMedication(id: string): Promise<Medication> { async getMedication(id: string): Promise<MedicationWireResponse> {
const response = await this.client.get<Medication>(`/medications/${id}`); const response = await this.client.get<MedicationWireResponse>(`/medications/${id}`);
return response.data; return response.data;
} }
async createMedication(data: CreateMedicationRequest): Promise<Medication> { async createMedication(data: CreateMedicationRequest): Promise<MedicationWireResponse> {
const response = await this.client.post<Medication>('/medications', data); const response = await this.client.post<MedicationWireResponse>('/medications', data);
return response.data; return response.data;
} }
async updateMedication(id: string, data: UpdateMedicationRequest): Promise<Medication> { async updateMedication(id: string, data: UpdateMedicationRequest): Promise<MedicationWireResponse> {
// Backend update is POST /:id (not PUT). const response = await this.client.post<MedicationWireResponse>(`/medications/${id}`, data);
const response = await this.client.post<Medication>(`/medications/${id}`, data);
return response.data; return response.data;
} }
async deleteMedication(id: string): Promise<void> { async deleteMedication(id: string): Promise<void> {
// Backend delete is POST /:id/delete (not DELETE /:id).
await this.client.post(`/medications/${id}/delete`); await this.client.post(`/medications/${id}/delete`);
} }
// ---- Dose logging + adherence (Phase 3c) ---- // ---- Dose logging + adherence (not encrypted — counts/flags only) ----
async logDose(medicationId: string, req: LogDoseRequest): Promise<DoseLog> { async logDose(medicationId: string, req: LogDoseRequest): Promise<DoseLog> {
const response = await this.client.post<DoseLog>(`/medications/${medicationId}/log`, req); const response = await this.client.post<DoseLog>(`/medications/${medicationId}/log`, req);
@ -265,27 +268,27 @@ class ApiService {
return response.data; return response.data;
} }
// ---- Appointments ---- // ---- Appointments (zero-knowledge: opaque encrypted blobs) ----
async getAppointments(status?: string): Promise<Appointment[]> { async getAppointments(status?: string): Promise<AppointmentWireResponse[]> {
const response = await this.client.get<Appointment[]>('/appointments', { const response = await this.client.get<AppointmentWireResponse[]>('/appointments', {
params: status ? { status } : undefined, params: status ? { status } : undefined,
}); });
return response.data; return response.data;
} }
async getAppointment(id: string): Promise<Appointment> { async getAppointment(id: string): Promise<AppointmentWireResponse> {
const response = await this.client.get<Appointment>(`/appointments/${id}`); const response = await this.client.get<AppointmentWireResponse>(`/appointments/${id}`);
return response.data; return response.data;
} }
async createAppointment(data: CreateAppointmentRequest): Promise<Appointment> { async createAppointment(data: CreateAppointmentRequest): Promise<AppointmentWireResponse> {
const response = await this.client.post<Appointment>('/appointments', data); const response = await this.client.post<AppointmentWireResponse>('/appointments', data);
return response.data; return response.data;
} }
async updateAppointment(id: string, data: UpdateAppointmentRequest): Promise<Appointment> { async updateAppointment(id: string, data: UpdateAppointmentRequest): Promise<AppointmentWireResponse> {
const response = await this.client.post<Appointment>(`/appointments/${id}`, data); const response = await this.client.post<AppointmentWireResponse>(`/appointments/${id}`, data);
return response.data; return response.data;
} }

View file

@ -1,4 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { deriveAuthAndEncKeys, setEncKey, clearEncKey } from '../crypto';
import { encryptJson } from '../crypto';
// Mock the api client so the store's real reducer logic is exercised without HTTP. // Mock the api client so the store's real reducer logic is exercised without HTTP.
const apiMock = { const apiMock = {
@ -13,52 +15,87 @@ vi.mock('../services/api', () => ({ default: apiMock }));
const { useMedicationStore } = await import('./useStore'); const { useMedicationStore } = await import('./useStore');
describe('useMedicationStore', () => { describe('useMedicationStore', () => {
beforeEach(() => { let encKey: CryptoKey;
beforeEach(async () => {
// Derive a real encryption key so the store can encrypt/decrypt.
const { encKey: key } = await deriveAuthAndEncKeys('test-password');
encKey = key;
setEncKey(encKey);
useMedicationStore.setState({ useMedicationStore.setState({
medications: [], medications: [],
isLoading: false, isLoading: false,
error: null, error: null,
adherence: {}, adherence: {},
selectedMedication: null,
}); });
apiMock.getMedications.mockReset(); apiMock.getMedications.mockReset();
apiMock.createMedication.mockReset(); apiMock.createMedication.mockReset();
}); });
it('loadMedications populates state from the api client', async () => { afterEach(() => {
const meds = [{ medication_id: 'm1', name: 'Aspirin', dosage: '100mg' }]; clearEncKey();
apiMock.getMedications.mockResolvedValue(meds); });
it('loadMedications decrypts wire responses into domain medications', async () => {
// The API returns opaque wire blobs; the store decrypts them.
const blob = await encryptJson({ name: 'Aspirin', dosage: '100mg', frequency: 'daily' }, encKey);
apiMock.getMedications.mockResolvedValue([
{
id: 'oid1',
medication_id: 'm1',
user_id: 'u1',
profile_id: 'p1',
active: true,
encrypted_data: blob,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
]);
await useMedicationStore.getState().loadMedications(); await useMedicationStore.getState().loadMedications();
expect(apiMock.getMedications).toHaveBeenCalledOnce(); expect(apiMock.getMedications).toHaveBeenCalledOnce();
expect(useMedicationStore.getState().medications).toEqual(meds); const meds = useMedicationStore.getState().medications;
expect(useMedicationStore.getState().isLoading).toBe(false); expect(meds).toHaveLength(1);
expect(meds[0].name).toBe('Aspirin');
expect(meds[0].dosage).toBe('100mg');
expect(meds[0].active).toBe(true);
}); });
it('loadMedications sets an error message on failure (and does not throw)', async () => { it('loadMedications sets an error when no enc key is available', async () => {
apiMock.getMedications.mockRejectedValue(new Error('boom')); clearEncKey();
// Should NOT throw — load actions swallow.
await useMedicationStore.getState().loadMedications(); await useMedicationStore.getState().loadMedications();
expect(useMedicationStore.getState().error).toMatch(/No encryption key/);
expect(useMedicationStore.getState().error).toBe('boom');
expect(useMedicationStore.getState().isLoading).toBe(false);
}); });
it('createMedication appends the new medication and re-throws on error', async () => { it('createMedication encrypts domain data and stores the decrypted result', async () => {
apiMock.createMedication.mockResolvedValue({ medication_id: 'm2', name: 'New' }); const blob = await encryptJson({ name: 'New', dosage: '50mg', frequency: 'daily' }, encKey);
apiMock.createMedication.mockResolvedValue({
id: 'oid2',
medication_id: 'm2',
user_id: 'u1',
profile_id: 'p1',
active: true,
encrypted_data: blob,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
});
await useMedicationStore.getState().createMedication({ name: 'New' }); await useMedicationStore.getState().createMedication({
name: 'New',
dosage: '50mg',
frequency: 'daily',
route: 'oral',
profile_id: 'p1',
active: true,
});
expect(useMedicationStore.getState().medications).toEqual([ const meds = useMedicationStore.getState().medications;
{ medication_id: 'm2', name: 'New' }, expect(meds).toHaveLength(1);
]); expect(meds[0].name).toBe('New');
expect(meds[0].dosage).toBe('50mg');
// Now an error path: createMedication rejects -> store re-throws.
apiMock.createMedication.mockRejectedValue(new Error('nope'));
await expect(
useMedicationStore.getState().createMedication({ name: 'Bad' }),
).rejects.toThrow('nope');
}); });
it('logDose logs the dose then refreshes adherence', async () => { it('logDose logs the dose then refreshes adherence', async () => {

View file

@ -1,5 +1,13 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware'; import { devtools, persist } from 'zustand/middleware';
import {
deriveAuthAndEncKeys,
setEncKey,
clearEncKey,
getEncKey,
encryptJson,
decryptJson,
} from '../crypto';
import { import {
User, User,
Medication, Medication,
@ -88,8 +96,10 @@ interface AppointmentState {
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
loadAppointments: (status?: string) => Promise<void>; loadAppointments: (status?: string) => Promise<void>;
createAppointment: (data: CreateAppointmentRequest) => Promise<void>; // The store receives DOMAIN data (decrypted fields from the UI) and encrypts
updateAppointment: (id: string, data: UpdateAppointmentRequest) => Promise<void>; // 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>; deleteAppointment: (id: string) => Promise<void>;
clearError: () => void; clearError: () => void;
} }
@ -108,7 +118,11 @@ export const useAuthStore = create<AuthState>()(
login: async (email: string, password: string) => { login: async (email: string, password: string) => {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { 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({ set({
user: { user: {
user_id: response.user_id, user_id: response.user_id,
@ -121,6 +135,7 @@ export const useAuthStore = create<AuthState>()(
isLoading: false, isLoading: false,
}); });
} catch (error: any) { } catch (error: any) {
clearEncKey();
set({ set({
error: error.message || 'Login failed', error: error.message || 'Login failed',
isLoading: false, isLoading: false,
@ -133,11 +148,12 @@ export const useAuthStore = create<AuthState>()(
register: async (username: string, email: string, password: string) => { register: async (username: string, email: string, password: string) => {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const { authSecret, encKey } = await deriveAuthAndEncKeys(password);
setEncKey(encKey);
const response = await apiService.register({ const response = await apiService.register({
username, username,
email, email,
password, password: authSecret,
role: 'patient',
}); });
set({ set({
user: { user: {
@ -161,6 +177,7 @@ export const useAuthStore = create<AuthState>()(
}, },
logout: async () => { logout: async () => {
clearEncKey();
await apiService.logout(); await apiService.logout();
set({ set({
user: null, user: null,
@ -215,9 +232,42 @@ export const useMedicationStore = create<MedicationState>()(
adherence: {}, adherence: {},
loadMedications: async () => { 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 }); set({ isLoading: true, error: null });
try { 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 }); set({ medications, isLoading: false });
} catch (error: any) { } catch (error: any) {
set({ set({
@ -228,9 +278,42 @@ export const useMedicationStore = create<MedicationState>()(
}, },
createMedication: async (data: any) => { 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 }); set({ isLoading: true, error: null });
try { 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) => ({ set((state) => ({
medications: [...state.medications, medication], medications: [...state.medications, medication],
isLoading: false, isLoading: false,
@ -245,12 +328,33 @@ export const useMedicationStore = create<MedicationState>()(
}, },
updateMedication: async (id: string, data: any) => { 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 }); set({ isLoading: true, error: null });
try { 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) => ({ set((state) => ({
medications: state.medications.map((med) => 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, isLoading: false,
})); }));
@ -451,9 +555,30 @@ export const useProfileStore = create<ProfileState>()(
error: null, error: null,
loadProfile: async () => { 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 }); set({ isLoading: true, error: null });
try { 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 }); set({ profile, isLoading: false });
} catch (error: any) { } catch (error: any) {
set({ set({
@ -464,9 +589,28 @@ export const useProfileStore = create<ProfileState>()(
}, },
updateName: async (name) => { 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 }); set({ isLoading: true, error: null });
try { 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 }); set({ profile, isLoading: false });
} catch (error: any) { } catch (error: any) {
set({ set({
@ -489,9 +633,36 @@ export const useAppointmentStore = create<AppointmentState>()(
error: null, error: null,
loadAppointments: async (status) => { 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 }); set({ isLoading: true, error: null });
try { 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 }); set({ appointments, isLoading: false });
} catch (error: any) { } catch (error: any) {
set({ set({
@ -502,11 +673,40 @@ export const useAppointmentStore = create<AppointmentState>()(
}, },
createAppointment: async (data) => { 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 }); set({ isLoading: true, error: null });
try { 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) => ({ set((state) => ({
appointments: [...state.appointments, appt], appointments: [...state.appointments, appointment],
isLoading: false, isLoading: false,
})); }));
} catch (error: any) { } catch (error: any) {
@ -519,11 +719,29 @@ export const useAppointmentStore = create<AppointmentState>()(
}, },
updateAppointment: async (id, data) => { updateAppointment: async (id, data) => {
const key = getEncKey();
if (!key) {
set({ error: 'No encryption key — cannot encrypt data' });
throw new Error('No encryption key');
}
try { 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) => ({ set((state) => ({
appointments: state.appointments.map((a) => 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) { } catch (error: any) {

View file

@ -110,6 +110,14 @@ export interface PillIdentification {
custom_color?: string; custom_color?: string;
} }
// ---- Opaque encrypted blob wire types (zero-knowledge) ----
export interface EncryptedFieldWire {
data: string;
iv: string;
auth_tag?: string;
}
// ---- Medication: domain type (decrypted, used by UI) ----
export interface Medication { export interface Medication {
id?: string; id?: string;
medication_id?: string; medication_id?: string;
@ -129,39 +137,32 @@ export interface Medication {
notes?: string; notes?: string;
tags?: string[]; tags?: string[];
active: boolean; active: boolean;
pill_identification?: PillIdentification;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
} }
export interface CreateMedicationRequest { // Medication wire response (opaque blob from the server)
name: string; export interface MedicationWireResponse {
dosage: string; id: string;
frequency: string; medication_id: string;
route: string; user_id: string;
profile_id: string; profile_id: string;
reason?: string; active: boolean;
instructions?: string; encrypted_data: EncryptedFieldWire;
side_effects?: string[]; created_at: string;
prescribed_by?: string; updated_at: string;
prescribed_date?: string; }
start_date?: string;
end_date?: string; // Medication create/update requests (send encrypted blob to the server)
notes?: string; export interface CreateMedicationRequest {
tags?: string[]; profile_id: string;
reminder_times?: string[]; encrypted_data: EncryptedFieldWire;
pill_identification?: PillIdentification; active?: boolean;
} }
export interface UpdateMedicationRequest { export interface UpdateMedicationRequest {
name?: string; encrypted_data?: EncryptedFieldWire;
dosage?: string;
frequency?: string;
start_date?: string;
end_date?: string;
instructions?: string;
active?: boolean; active?: boolean;
pill_identification?: PillIdentification;
} }
// Drug Interaction Types (Phase 2.8) // Drug Interaction Types (Phase 2.8)
@ -244,7 +245,7 @@ export interface LabResult {
created_at?: string; created_at?: string;
} }
// Appointment Types — match backend AppointmentResponse (snake_case). // Appointment: domain type (decrypted, used by UI)
export interface Appointment { export interface Appointment {
id?: string; id?: string;
appointment_id: string; appointment_id: string;
@ -263,31 +264,41 @@ export interface Appointment {
updated_at?: string; updated_at?: string;
} }
export interface CreateAppointmentRequest { // Appointment wire response (opaque blob)
title: string; export interface AppointmentWireResponse {
provider: string; id: string;
appointment_type: string; appointment_id: string;
date_time: string; user_id: string;
profile_id: string; profile_id: string;
location?: string; status: string;
duration_minutes?: number; encrypted_data: EncryptedFieldWire;
reason?: string; created_at: string;
notes?: string; updated_at: string;
}
export interface CreateAppointmentRequest {
profile_id: string;
encrypted_data: EncryptedFieldWire;
status?: string; status?: string;
} }
export interface UpdateAppointmentRequest { export interface UpdateAppointmentRequest {
title?: string; encrypted_data?: EncryptedFieldWire;
provider?: string;
appointment_type?: string;
date_time?: string;
location?: string;
duration_minutes?: number;
reason?: string;
notes?: string;
status?: string; status?: string;
} }
// Profile wire response (opaque encrypted name)
export interface ProfileWireResponse {
profile_id: string;
user_id: string;
name_data: string;
name_iv: string;
role: string;
permissions: string[];
created_at: string;
updated_at: string;
}
// Dose Log Types — match backend MedicationDose (camelCase serialization). // Dose Log Types — match backend MedicationDose (camelCase serialization).
export interface DoseLog { export interface DoseLog {
id?: string; id?: string;