Picks application-level field encryption via prisma-field-encryption (AES-256-GCM, master key from KMS) as the primary defence, combined with volume encryption on the production Postgres host as defence in depth. Documents: - which fields are sensitive now (Profile.weight*, Profile.targetDate) and which arrive in V1 (WeightEntry, FastEvent, ProgressPhoto) - which fields stay in clear text and why (email/login, age/gender for aggregate analytics, height/activity for plan generation) - alternatives rejected: pgcrypto (key in queries), volume-only (no app-level protection), client-side E2E (kills BMR/TDEE server-side calculation) - consequences and the implementation roadmap for a follow-up PR Status: proposed — must be implemented before opening V1 to public users. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8.1 KiB
8.1 KiB
ADR 0002 — Crittografia at-rest dei dati sanitari
- Status: proposed (richiede implementazione prima del lancio pubblico V1)
- Date: 2026-04-29
- Decision makers: Luciano (PO), Claude
- Related: CLAUDE.md ("Vincoli non negoziabili" — GDPR by design)
Contesto
KetoPath tratta dati di salute soggetti all'art. 9 GDPR (categorie particolari di dati personali). Lo scope copre, oggi e a roadmap:
| Tabella | Campi sensibili | Quando arriva |
|---|---|---|
profiles |
weight_start_kg, weight_current_kg, weight_goal_kg, age, target_date |
già in schema |
weight_entries (V1) |
weight, measurements (girovita/fianchi/coscia/braccio), energy/sleep/hunger |
V1 (PRD §5.2) |
progress_photos (V1) |
binari foto utente (Cloudflare R2) | V1 |
fast_events (V1) |
symptoms[] (mal di testa, fame, lucidità), durata digiuno |
V1 (PRD §5.3) |
users / accounts |
email, password hash (gestiti da Better Auth — già scrypt) | già in schema |
CLAUDE.md include il vincolo "Crittografia at-rest sul DB" come non negoziabile, e l'ADR 0001 (auth) si è basato su Postgres self-hosted in EU come misura primaria. Manca però una decisione strutturata su quale strato debba cifrare e con quale key management. Questo ADR risolve quella lacuna.
Opzioni considerate
A) Crittografia di volume (disco / VM)
- Cosa cifra: l'intero filesystem dove vivono i datafile Postgres.
- Pro: trasparente, zero codice; copre tutto incluso WAL e backup se anche le stripe di backup sono cifrate.
- Contro: protegge solo da furto fisico del disco. Un attaccante con accesso al DB live (SQL injection, credenziali compromesse, dump operatore) legge i dati in chiaro. Insufficiente da solo per art. 9 GDPR — il Garante e l'EDPB (Linee guida 14/2019) chiedono pseudonimizzazione/cifratura dei dati sensibili stessi, non solo dello storage.
B) Postgres pgcrypto (cifratura a livello di colonna gestita dal DB)
- Cosa cifra: singole colonne via
pgp_sym_encrypt(value, key). - Pro: nativa, ben testata, niente dipendenze applicative.
- Contro: la chiave deve essere passata in ogni query, è quindi visibile nei log/
pg_stat_activity; rompe gli indici classici (servono index su funzioni); il modello Prisma deve usare raw queries per i campi cifrati. Maintenance burden alto.
C) Cifratura applicativa via prisma-field-encryption (raccomandato)
- Cosa cifra: campi annotati nello schema Prisma con un commento
/// @encrypted. La libreria intercetta tutte lecreate/update/findX/deletein middleware Prisma, cifra prima di scrivere e decifra dopo aver letto. - Algoritmo: AES-256-GCM, IV per record, integrity tag. La chiave master è 32 byte derivati da env (
PRISMA_FIELD_ENCRYPTION_KEY). - Pro:
- Trasparente al codice applicativo (
prisma.profile.updatecontinua a funzionare con valori in chiaro lato app). - Le chiavi non finiscono mai nel DB né nei log SQL.
- Supporta key rotation con
@encrypted?with=v2in seconda chiave, per deprecare gradualmente la vecchia. - Hash-based search index ausiliario (
@encrypted?mode=strict+ colonna hash) per equality lookups.
- Trasparente al codice applicativo (
- Contro:
- Rompe i range query e gli
ORDER BYsui campi cifrati — non possiamo fareWHERE weight > 70. Mitigazione: lasciare in chiaro i campi su cui DOBBIAMO filtrare per range (es. nessuno per ora; trend del peso si calcola lato app dallaWeightEntrycifrata leggendo l'intera serie utente). - Non cifra il filesystem né i backup automaticamente: serve combinare con A) per la difesa fisica.
- Migrazione dei dati esistenti richiede backfill (oggi è un MVP in dev, l'unico utente reale è il PO — backfill triviale).
- Rompe i range query e gli
D) Crittografia client-side end-to-end
- Cosa cifra: tutto cifrato sul device dell'utente con chiave derivata da password.
- Pro: niente accesso del nostro server in chiaro, max privacy.
- Contro: non possiamo calcolare BMR/TDEE server-side, impossibile generare piani alimentari personalizzati (la funzionalità centrale del prodotto). Reset password = perdita dati. Fuori scope.
Decisione
Adottiamo il pattern C (cifratura applicativa via prisma-field-encryption) come strato primario, combinato con A (crittografia di volume) sul Postgres di produzione come difesa in profondità.
Algoritmo / KMS:
- AES-256-GCM, una sola chiave master per ambiente (dev/staging/prod), distinte.
- Storage chiave: variable d'ambiente
PRISMA_FIELD_ENCRYPTION_KEYin dev/staging; AWS KMS (o equivalente EU — OVH Vault, Scaleway Secret Manager) in produzione, rotazione annuale documentata. - Niente chiavi nel repository, niente chiavi in
.env.example, niente nei log.
Campi da cifrare (priorità all'implementazione):
| Modello | Campi | Note |
|---|---|---|
Profile |
weightStartKg, weightCurrentKg, weightGoalKg, targetDate |
Decimal → string cifrato |
WeightEntry (V1) |
weight, measurements, energy, sleep, hunger, notes |
tutto |
FastEvent (V1) |
symptoms, notes |
il timer in sé non sensibile |
ProgressPhoto (V1) |
URL R2 + chiave di decifratura della foto cifrata | foto cifrate prima di R2 |
NON cifriamo:
users.email(serve come chiave di login)users.name,users.image(richiesti da Better Auth e dall'UI)profiles.age,profiles.gender,profiles.heightCm,profiles.activityLevel— non identificativi da soli, e servono per query di aggregazione/statistiche anonime sul prodotto
Conseguenze
Positive
- Difesa multistrato: anche con dump del DB live, i campi sanitari restano cifrati senza chiave master.
- Compatibile con Prisma esistente, niente raw SQL.
- Difendibile in audit GDPR: cifratura art. 32 GDPR dimostrata sui dati art. 9.
Negative
- Niente range query/ordini sui campi cifrati. Le query del trend peso (PRD §9.5) dovranno leggere la serie completa per utente e fare il calcolo lato app.
- La perdita della chiave master = perdita dei dati cifrati. Mitigazione: backup chiave su KMS managed (rischio ridotto), runbook di key recovery scritto.
- Una migrazione futura di provider DB richiederà di portare anche la chiave o di eseguire un re-encrypt.
Implementazione (da pianificare in ADR/PR successivo)
Roadmap proposta (non in scope per questo ADR):
- Aggiungere
prisma-field-encryptioncome dipendenza di@ketopath/db. - Definire
PRISMA_FIELD_ENCRYPTION_KEYin env validation (@ketopath/db/src/env.ts). - Wrap di
PrismaClientconfieldEncryptionExtension(). - Annotare lo schema con i commenti
/// @encryptedsui campi della tabella precedente. - Migrazione: per il MVP locale, fare un backfill via script che rilegge i record esistenti e li riscrive (cifrati al rewrite). Per produzione, script idempotente eseguito una sola volta.
- Test: unit test che verifichi un round-trip via
prisma.profile.findUniqueritorni il valore in chiaro; integration test sul DB che mostri il valore cifrato in raw SQL. - Aggiornare il runbook di backup: i backup conservano i dati cifrati — la chiave non viene mai inclusa.
- Disaster recovery: documentare cosa succede se la chiave master è persa.
Vincoli operativi
- Mai committare la chiave master.
- Ogni accesso operatore al DB di produzione deve passare per un bastion con audit trail (out of scope di questo ADR ma pianificato).
- I log applicativi non devono mai contenere il payload deserializzato di campi cifrati. Usare solo l'
iddel record per riferimenti operativi. - DPIA aggiornata quando introduciamo la cifratura, indicando: scope, algoritmo, gestione chiave, retention dei dati cifrati, processo di key rotation.