docs(adr): 0002 encryption at-rest plan for art. 9 GDPR data
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>
This commit is contained in:
1 parent
02af6685cf
commit
f954be610b
1 file changed
+113
@@ -0,0 +1,113 @@
|
||||
# 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 le `create/update/findX/delete` in 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.update` continua 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=v2` in seconda chiave, per deprecare gradualmente la vecchia.
|
||||
- Hash-based search index ausiliario (`@encrypted?mode=strict` + colonna hash) per equality lookups.
|
||||
- **Contro**:
|
||||
- Rompe i range query e gli `ORDER BY` sui campi cifrati — non possiamo fare `WHERE 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 dalla `WeightEntry` cifrata 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).
|
||||
|
||||
### 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_KEY` in 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):
|
||||
|
||||
1. Aggiungere `prisma-field-encryption` come dipendenza di `@ketopath/db`.
|
||||
2. Definire `PRISMA_FIELD_ENCRYPTION_KEY` in env validation (`@ketopath/db/src/env.ts`).
|
||||
3. Wrap di `PrismaClient` con `fieldEncryptionExtension()`.
|
||||
4. Annotare lo schema con i commenti `/// @encrypted` sui campi della tabella precedente.
|
||||
5. 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.
|
||||
6. Test: unit test che verifichi un round-trip via `prisma.profile.findUnique` ritorni il valore in chiaro; integration test sul DB che mostri il valore cifrato in raw SQL.
|
||||
7. Aggiornare il runbook di backup: i backup conservano i dati cifrati — la chiave non viene mai inclusa.
|
||||
8. 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'`id` del record per riferimenti operativi.
|
||||
- DPIA aggiornata quando introduciamo la cifratura, indicando: scope, algoritmo, gestione chiave, retention dei dati cifrati, processo di key rotation.
|
||||
Reference in new issue
Block a user