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 { format } from 'date-fns';
import { useAppointmentStore, useAuthStore } from '../../store/useStore';
import type {
Appointment,
CreateAppointmentRequest,
UpdateAppointmentRequest,
} from '../../types/api';
import type { Appointment } from '../../types/api';
const APPT_TYPES = ['in-person', 'telehealth', 'lab', 'test', 'other'];
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' => {
if (status === 'upcoming') return 'success';
if (status === 'cancelled') return 'error';
return 'default';
};
const emptyCreate: CreateAppointmentRequest = {
const emptyCreate: ApptFormData = {
title: '',
provider: '',
appointment_type: 'in-person',
@ -67,9 +77,9 @@ export const AppointmentsManager: FC = () => {
const user = useAuthStore((s) => s.user);
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 [editForm, setEditForm] = useState<UpdateAppointmentRequest>({});
const [editForm, setEditForm] = useState<Partial<ApptFormData>>({});
const [deleteTarget, setDeleteTarget] = useState<Appointment | null>(null);
const [saving, setSaving] = useState(false);
@ -88,7 +98,7 @@ export const AppointmentsManager: FC = () => {
const submitCreate = async () => {
setSaving(true);
try {
await createAppointment(createForm);
await createAppointment(createForm as unknown as Record<string, unknown>);
setCreateOpen(false);
} catch {
/* error surfaced via store */
@ -116,7 +126,7 @@ export const AppointmentsManager: FC = () => {
if (!editTarget?.appointment_id) return;
setSaving(true);
try {
await updateAppointment(editTarget.appointment_id, editForm);
await updateAppointment(editTarget.appointment_id, editForm as unknown as Record<string, unknown>);
setEditTarget(null);
} catch {
/* 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 AddIcon from '@mui/icons-material/Add';
import { useMedicationStore, useAuthStore } from '../../store/useStore';
import type {
Medication,
CreateMedicationRequest,
UpdateMedicationRequest,
} from '../../types/api';
import type { Medication } from '../../types/api';
import { DoseLogger } from './DoseLogger';
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: '',
dosage: '',
frequency: '',
@ -56,9 +63,9 @@ export const MedicationManager: FC = () => {
const user = useAuthStore((s) => s.user);
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 [editForm, setEditForm] = useState<UpdateMedicationRequest>({});
const [editForm, setEditForm] = useState<Partial<MedFormData>>({});
const [deleteTarget, setDeleteTarget] = useState<Medication | null>(null);
const [saving, setSaving] = useState(false);
@ -238,7 +245,7 @@ export const MedicationManager: FC = () => {
fullWidth
value={createForm.route}
onChange={(e) =>
setCreateForm({ ...createForm, route: e.target.value as CreateMedicationRequest['route'] })
setCreateForm({ ...createForm, route: e.target.value })
}
>
{ROUTES.map((r) => (