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
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue