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

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