
A privacy-first digital pre-consultation system for small dental clinics, built and deployed as a university extension practice (100h, PUCPR) at a real partner clinic. The patient fills a guided nine-step anamnesis before the appointment; the dentist receives a structured clinical summary — automatic ASA physical-status classification, dentistry-specific drug-interaction alerts, and a local-anesthetic safety evaluation with per-carpule epinephrine math. It replaces paper forms that were incomplete, illegible, and stored with no encryption at all — out of compliance with Brazil's LGPD. LOCAL-FIRST BY LAW, NOT BY PREFERENCE — Health data is LGPD Article 11 "sensitive data." The architectural answer: there is no server. The app is 100% static; every byte of patient data lives and dies in the browser. Drafts persist in localStorage with automatic 12-hour expiry, so a shared reception tablet never accumulates a shadow database of patient records. ENCRYPTION AT THE EDGE — Export is a .json file encrypted entirely client-side with AES-256-GCM, the key derived from a user PIN via PBKDF2 (SHA-256, 210,000 iterations), with a random salt and IV per file, all through the native Web Crypto API. GCM's authentication tag doubles as the wrong-PIN detector — a bad PIN fails the integrity check, so there is no decryption oracle and no silent garbage output. Any feature that touches the network sits behind an explicit consent toggle that is OFF by default. THE MEDICATION-ANALYSIS CASCADE — Drug analysis degrades gracefully from free-and-offline to paid-and-online, escalating only with consent: local catalog (~90 drugs) → local interaction rules → on-device parsing of the ANVISA package-insert PDF (pdf.js, regex over the regulator-mandated section headers) → RxNav/RxNorm public API (opt-in) → Claude API (opt-in, last resort). Patients rarely know their drug's active compound but they have the box — so the app reads the bula PDF in-browser and cross-references it locally, never sending anything unless the scanned-image fallback forces an explicit, disclosed AI escalation. ENGINEERING DISCIPLINE — 38 Vitest tests cover the risk surface: crypto round-trips, the ANVISA PDF parser, interaction rules, and the versioned encrypted-file envelope. CI on GitHub Actions. The clinical logic is a real rules engine, not a lookup — ASA classification emits the class, the determining factors, and a written rationale the dentist can defend.
// Health data (LGPD art. 11) never leaves the device. All crypto runs in-browser
// via the native Web Crypto API — the exported .json is unreadable without the PIN.
const KDF_ITERATIONS = 210_000; // PBKDF2 — high cost against offline brute force
export async function encryptFicha(data: unknown, pin: string): Promise<EncryptedPayload> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12)); // per-file, never reused
const baseKey = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(pin), "PBKDF2", false, ["deriveKey"],
);
const key = await crypto.subtle.deriveKey(
{ name: "PBKDF2", hash: "SHA-256", salt, iterations: KDF_ITERATIONS },
baseKey, { name: "AES-GCM", length: 256 }, false, ["encrypt"],
);
const plaintext = new TextEncoder().encode(JSON.stringify(data));
const cipher = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext);
// GCM's auth tag doubles as the wrong-PIN detector: a bad PIN fails the
// integrity check and decrypt() throws — no oracle, no silent garbage output.
return {
_type: "ficha-clinica", _version: 1, encrypted: true,
savedAt: new Date().toISOString(),
kdf: { name: "PBKDF2", hash: "SHA-256", iterations: KDF_ITERATIONS, salt: b64(salt) },
iv: b64(iv),
ciphertext: b64(new Uint8Array(cipher)),
};
}AES-256-GCM + PBKDF2, entirely in-browser. Sensitive health data never leaves the device.