Ja tenim totes les peces: AEAD versionat, KDF, signatures, TLS, mTLS i, des de la lliçó anterior, un gestor de secrets amb envelope encryption. Aquesta lliçó canvia d'altitud: deixem de mirar primitives i adoptem la visió d'arquitecte. La pregunta ja no és "com funciona AES-GCM?" sinó "quines dades de MediNube estan xifrades, amb quina capa, i contra quin atacant protegeix cada capa?". Dibuixaràs el mapa complet de la dada — des del navegador d'Ana Pérez fins a la fila de PostgreSQL i la cinta de còpia de seguretat— i descobriràs que "està xifrat" és una frase que no vol dir res fins que respons: xifrat on i contra qui?

Contingut

  1. El mapa de dades de MediNube: estats i fronteres
  2. En trànsit: TLS extern i intern (la BD també parla TLS)
  3. En repòs: les quatre capes i quina amenaça cobreix cadascuna
  4. Cerca sobre dades xifrades: el dilema i l'índex cec
  5. Fer hash, xifrar o deixar en clar: la taula de decisió
  6. El bolcat robat: exercici mental guiat
  7. Xifratge d'extrem a extrem: el maximalisme i per què MediNube no ho és

El mapa de dades de MediNube: estats i fronteres

Tota dada està en un de dos estats: en trànsit (viatjant per una xarxa) o en repòs (escrita en un disc). Cada estat té les seves amenaces i les seves eines. Aquest és el mapa de MediNube amb el que ja hem construït:

flowchart LR
    subgraph internet["Internet"]
        NAV["Navegador d'Ana Perez"]
        CLI["Sistemes de Clinica Sol<br/>(mTLS, 05-02)"]
    end
    subgraph dmz["Perimetre MediNube"]
        NGINX["nginx<br/>TLS 1.3 (05-02)<br/>/etc/medinube/tls/"]
    end
    subgraph interna["Xarxa interna"]
        APP["App MediNube<br/>xifratge d'aplicacio (02-03)<br/>DEK via KMS (06-01)"]
        PG[("PostgreSQL<br/>TLS intern + disc xifrat")]
        BK[("Copies de seguretat<br/>scrypt (02-04) + sobre (04-05)")]
        S3[("Emmagatzematge d'objectes<br/>adjunts xifrats")]
    end
    NAV -->|"TLS 1.3"| NGINX
    CLI -->|"mTLS"| NGINX
    NGINX -->|"TLS intern"| APP
    APP -->|"TLS: sslmode=verify-full"| PG
    APP -->|"TLS"| S3
    PG -->|"pg_dump xifrat"| BK

Lliçó d'arquitecte número u: les fletxes són trànsit (domini de TLS, mòdul 5) i els cilindres són repòs (domini dels mòduls 2 i 4). Lliçó número dos: l'interior de la xarxa "de confiança" també porta cadenats — l'època de "TLS només al perímetre" es va acabar quan els atacants van aprendre a moure's lateralment per xarxes internes.

En trànsit: TLS extern i intern

El tram extern ja el vam resoldre al mòdul 5 i aquí només el reutilitzem: TLS 1.3 a portal.medinube.example i api.medinube.example, mTLS per a les clíniques amb el seu SAN clinica-sol.clientes.medinube.example, HSTS, certificats de vida curta amb ACME. Res de nou.

El que sí que és nou és mirar cap dins: la connexió app ↔ PostgreSQL també travessa una xarxa, i en aquesta xarxa hi pot haver un contenidor compromès escoltant. PostgreSQL parla TLS de manera nativa, i el client decideix quant n'exigeix amb sslmode:

sslmode Xifra? Verifica el certificat? Verifica el nom del servidor?
disable No
require No No
verify-ca Sí (contra una CA) No
verify-full

Aquí hi ha el parany que sorprèn gairebé tothom: require xifra però no verifica. Accepta qualsevol certificat, inclòs l'autosignat que MalloryClinic presenti en un MITM dins la xarxa. És el verify=False de 05-02 disfressat d'opció raonable: canal xifrat amb un desconegut. La configuració correcta fa servir la CA interna que vam crear a 05-01:

# medinube/db.py — connexio a PostgreSQL amb TLS verificat de veritat
import psycopg

connexio = psycopg.connect(
    host="pg.interna.medinube.example",
    dbname="medinube",
    user="app_medinube",
    password=obtenir_del_gestor("medinube/produccio/db-password"),  # 06-01
    sslmode="verify-full",                # xifratge + cadena + nom de l'amfitrio
    sslrootcert="/etc/medinube/pki/ca.crt",  # la nostra CA interna de 05-01
)

Explicació: sslrootcert apunta a l'arrel MediNube Lab Root CA desplegada a 05-01; verify-full obliga que el certificat del servidor estigui signat per aquesta CA i que el seu SAN coincideixi amb pg.interna.medinube.example. És exactament la validació de cadena+nom del mòdul 5, aplicada a la base de dades. La contrasenya, per descomptat, surt del gestor de la lliçó anterior.

En repòs: les quatre capes i quina amenaça cobreix cadascuna

"La base de dades està xifrada" pot voler dir quatre coses molt diferents. La taula que segueix és probablement la més important de la lliçó — cada capa protegeix contra un atacant concret i és transparent per a tots els altres:

Capa Exemple Protegeix contra NO protegeix contra
Disc / volum LUKS, xifratge per defecte del cloud Robatori físic del disc, RMA/rebuig de maquinari Qualsevol atacant amb el sistema operatiu arrencat (el SO veu el disc desxifrat)
Base de dades (TDE) TDE de motors comercials; pgcrypto per columnes Robatori dels fitxers de dades i d'algunes còpies de seguretat en fred Atacant amb accés SQL, injecció SQL, DBA maliciós (segons la variant)
Aplicació medinube.cripto (02-03): AEAD abans de l'INSERT BD compromesa completament: bolcat, injecció, DBA curiós Compromís de la mateixa app o del KMS; no permet consultar el camp
Còpies de seguretat scrypt (02-04) + sobre híbrid (04-05) Pèrdua/robatori del suport de còpia, repositoris de còpies aliens Res més: és xifratge d'aplicació aplicat al fitxer de còpia

Tres comentaris de profunditat:

  • El xifratge de disc és necessari i gairebé mai suficient. Quan el servidor està encès (sempre, en un SaaS), LUKS és transparent: qualsevol procés amb permisos llegeix les dades en clar. El seu escenari real és el disc que surt del centre de dades. Activa'l sempre (al cloud sol venir de sèrie), però no el posis a l'informe de seguretat com "els historials estan xifrats".

  • TDE i pgcrypto viuen dins la BD. TDE (Transparent Data Encryption) xifra els fitxers del motor: mateix abast que el disc, més granular. pgcrypto permet xifrar columnes des de SQL:

    -- pgcrypto: xifratge simetric per columna, des del mateix SQL
    UPDATE pacients
       SET telefon = pgp_sym_encrypt('612345678', 'clau-secreta')
     WHERE id = 4471;
    
    SELECT pgp_sym_decrypt(telefon::bytea, 'clau-secreta') FROM pacients;
    

    El seu límit salta a la vista: la clau viatja a la consulta SQL, amb la qual cosa acaba als logs de la BD, a pg_stat_activity i a mans de qualsevol atacant que ja estigui dins del motor... que és justament l'atacant del qual volíem protegir-nos. Útil en escenaris acotats; no per als historials de MediNube.

  • El xifratge a nivell d'aplicació és el més granular i el més fort. És el que vam construir a 02-03: l'historial d'Ana Pérez es xifra amb AES-256-GCM (format v1, AAD pacient=...;format=v1) abans de sortir de l'app, i viatja ja xifrat fins a la fila de PostgreSQL. La BD només veu bytes opacs: un bolcat complet, una injecció SQL o un administrador curiós no obtenen absolutament res. La DEK arriba per l'envelope encryption de 06-01. El preu també és real, i li dediquem la secció següent: la BD ja no pot indexar ni cercar sobre aquest camp.

Les capes no competeixen: s'apilen. MediNube porta disc xifrat (contra el robatori físic), TLS intern (contra la xarxa), xifratge d'aplicació als camps sensibles (contra la BD compromesa) i còpies de seguretat xifrades amb el sobre híbrid de 04-05 quan el destinatari és extern (la clínica que demana la seva còpia: es xifra la DEK de la còpia amb la pública de la clínica).

Cerca sobre dades xifrades: el dilema i l'índex cec

Aquí apareix el conflicte central del xifratge d'aplicació. Un camp AEAD ben xifrat és indistingible de bytes aleatoris i, a més, xifrar dues vegades el mateix valor produeix blobs diferents (nonce aleatori). Conseqüència directa:

-- Aixo NO POT funcionar: cada xifratge del mateix DNI es diferent
SELECT * FROM pacients WHERE dni_xifrat = '???';

No és un defecte: és la semàntica de seguretat que volíem. Però recepció necessita cercar Ana Pérez per DNI. El patró pràctic és l'índex cec (blind index): junt al camp xifrat es desa un HMAC del valor normalitzat, amb una clau dedicada del gestor:

# medinube/indexs.py — index cec per a cerca per igualtat exacta
import hmac, hashlib

CLAU_INDEX_DNI = obtenir_del_gestor("medinube/produccio/index-dni")  # NOMES per a aixo

def index_cec_dni(dni: str) -> bytes:
    normalitzat = dni.strip().upper().replace("-", "").replace(" ", "")
    return hmac.new(CLAU_INDEX_DNI, normalitzat.encode(), hashlib.sha256).digest()

# En desar:  INSERT ... (dni_xifrat, dni_idx) VALUES (aead(dni), index_cec_dni(dni))
# En cercar: SELECT ... WHERE dni_idx = %s   ← determinista: mateix DNI, mateix HMAC

Per què cada peça és com és: la normalització garanteix que "12345678-Z" i "12345678z" produeixin el mateix índex (si no, la cerca falla per format); l'HMAC amb clau (i no un simple sha256) impedeix que qui robi la taula calculi l'índex de tots els DNI possibles per força bruta —els DNI tenen poquíssima entropia, regla d'or 5—; i la clau dedicada limita el dany si es filtra.

Honestedat obligada sobre el que l'índex cec filtra: és determinista, així que revela quines files comparteixen valor (igualtat) i permet confirmar un DNI concret a qui tingui la clau de l'índex. Només serveix per a igualtat exacta: res de LIKE 'PER%', rangs ni ordenació. Existeixen esquemes més ambiciosos (xifratge que preserva l'ordre, xifratge cercable), però les seves fuites són molt pitjors del que sembla i la seva història acadèmica és plena de trencaments: per a MediNube, igualtat exacta amb índex cec i qualsevol cerca més rica sobre camps no sensibles.

Fer hash, xifrar o deixar en clar: la taula de decisió

Amb les capes i l'índex cec sobre la taula, ja podem decidir camp a camp. Aquesta és la taula real de la taula pacients i companyia a MediNube — el criteri: cal recuperar el valor original? cal cercar-hi? què passa si es filtra?

Columna Tractament Per què
id (intern) Clar Identificador opac sense significat extern; les FK i JOIN el necessiten
dni AEAD + índex cec Cal mostrar-lo (recuperable) i cercar-hi (índex); dada identificativa forta
nom AEAD (xifrat) Recuperable per mostrar-lo; no es cerca per igualtat exacta (la cerca de pacients fa servir altres camins)
contrasenya Argon2id + pepper (03-03/06-01) Mai no necessitem l'original: verificar ≠ recuperar. Fer hash, mai xifrar
historial AEAD v1/v2 amb AAD La dada més sensible; mai no es consulta per contingut des de SQL
email AEAD + índex cec Recuperable (cal enviar-hi correus) i cercable (login)
token_recuperacio sha256(token) (03-03) Només es compara, mai no es mostra; el token real només el té l'usuari
data_alta, estat Clar Necessaris per operar, ordenar i depurar; risc baix aïllats

La fila de la contrasenya mereix subratllat: xifrar una contrasenya (recuperable) en lloc de fer-ne el hash és un error clàssic d'auditoria. Si el sistema pot recuperar la teva contrasenya, un atacant també.

El bolcat robat: exercici mental guiat

Posem a prova el disseny. MalloryClinic aconsegueix un pg_dump complet de la BD de MediNube (una injecció SQL, una còpia de seguretat mal protegida, tant se val el com). Recorrem què obté segons les capes presents:

  1. Sense cap capa (la MediNube del mòdul 1): tot en clar. Historials, DNI, contrasenyes. Bretxa catastròfica, notificació a l'AEPD, portada de premsa.
  2. Només disc xifrat: exactament el mateix que el punt 1. El bolcat es va fer amb el sistema arrencat; LUKS ni se'n va assabentar. Primera lliçó gravada a foc: el xifratge de disc no protegeix contra bolcats lògics.
  3. + TDE / xifratge de la BD: també gairebé el mateix: el bolcat lògic surt del motor, que desxifra de manera transparent. TDE protegiria el robatori dels fitxers del motor, no del dump.
  4. + xifratge d'aplicació (la MediNube actual): els historials i DNI són blobs AEAD sense les DEK (que són al KMS, no a la BD); les contrasenyes són Argon2id i a més falta el pepper (que viu al gestor); els índexs cecs no s'inverteixen sense la seva clau. Mallory té ids, dates i metadades. Continua sent un incident (les metadades també compten per al RGPD i cal notificar-lo), però el contingut clínic d'Ana Pérez està a salvo.

Aquest exercici —"què veu l'atacant amb aquest bolcat?"— és l'eina de disseny més barata que existeix. Fes-lo amb cada taula nova abans d'escriure la migració.

Xifratge d'extrem a extrem: el maximalisme i per què MediNube no ho és

Queda una capa per sobre de totes: el xifratge d'extrem a extrem (E2E), on ni tan sols el servidor pot llegir les dades — només els extrems tenen les claus. Signal, que ja vam esmentar al mòdul 4, és l'exemple canònic: el servidor de Signal transporta blobs que no pot obrir.

Podria MediNube ser E2E, amb els historials xifrats amb claus que només tinguessin les clíniques i els pacients? Tècnicament sí (les peces són les del mòdul 4: X25519, sobres, signatures). Però implicaria renunciar a funcions que avui són el producte: el servidor no podria generar informes agregats, ni indexar per a cerca, ni permetre que un metge d'urgències accedeixi a un historial si el pacient no hi és per "autoritzar" criptogràficament; i la pèrdua de la clau del pacient significaria la pèrdua irrecuperable del seu historial (o un sistema de recuperació... que reintrodueix la confiança en el servidor). E2E és l'elecció correcta quan el servidor és només un transport (missatgeria); MediNube és un processador de dades, i el seu compromís honest és un altre: xifratge d'aplicació amb claus en KMS auditat, de manera que llegir dades exigeixi comprometre dos sistemes i deixi rastre. Dir "som E2E" sense ser-ho seria màrqueting, no criptografia — i les auditories RGPD distingeixen perfectament les dues coses.

Errors Comuns i Consells

  • Error: sslmode=require i sensació de seguretat. Xifra, però accepta qualsevol certificat. Contra un MITM intern és inútil: verify-full + la teva CA interna, sempre.
  • Error: comptar el xifratge de disc com a xifratge de dades. Protegeix discos apagats. A l'informe de seguretat, especifica capa per capa què hi ha.
  • Error: passar la clau de pgcrypto a cada consulta i trobar-la després als logs de PostgreSQL. Si necessites protecció contra la mateixa BD, xifra a l'aplicació.
  • Error: índex cec amb hash sense clau (sha256(dni)): amb ~10⁸ DNI possibles, es capgira per força bruta en minuts. HMAC amb clau del gestor, i normalitza abans.
  • Consell: apila capes segons l'atacant, no segons la moda. La pregunta de disseny sempre és "contra qui?": robatori físic → disc; xarxa interna → TLS amb verify-full; BD compromesa → aplicació; suport extern → còpies de seguretat amb sobre.
  • Consell: documenta la taula de decisió de camps (clar/hash/xifrat/índex) al repositori. És or per al següent desenvolupador i per a l'auditor.

Exercicis

  1. Classifica la taula nova: MediNube afegeix la taula cites amb columnes id, pacient_id, metge_id, data_hora, motiu_consulta (text lliure: "dolor toràcic..."), telefon_contacte. Assigna tractament a cada columna (clar / AEAD / AEAD+índex cec / hash) i justifica-ho. Pista: recepció necessita cercar cites per telèfon de contacte.
  2. El bolcat, una altra vegada: amb el teu disseny de l'exercici 1, escriu què obté exactament MalloryClinic d'un pg_dump de cites, i quines metadades continuen filtrant-se encara que tot el sensible estigui xifrat. És "cita del pacient X amb el metge Y a les 9:00" una dada innòcua?
  3. Caçant el require: escriu la cadena de connexió (o el bloc psycopg.connect) incorrecta amb sslmode=require i la seva correcció amb verify-full, i explica en dues frases l'atac concret que la versió incorrecta permet dins la xarxa de MediNube.

Solucions

  1. id, pacient_id, metge_id: clar (claus de JOIN; són referències opaques). data_hora: clar (cal consultar agendes per rang: WHERE data_hora BETWEEN..., impossible sobre AEAD i l'índex cec no cobreix rangs). motiu_consulta: AEAD amb AAD (p. ex. f"cita={id};format=v2") — és contingut clínic, tan sensible com l'historial. telefon_contacte: AEAD + índex cec amb normalització (treure espais, prefix +34) perquè cal recuperar-lo (trucar al pacient) i cercar-hi.
  2. Mallory veu el graf de cites: quin pacient_id visita quin metge_id i quan, sense motius ni telèfons. No és innocu: si el metge Y és identificable com a oncòleg, la mera existència de cites freqüents revela informació de salut — per això les metadades també són dades personals sota el RGPD i l'incident igualment es notifica. Mitigacions possibles: pseudonimitzar metge_id a nivell d'aplicació o xifrar també aquesta relació acceptant el cost en consultes.
  3. Incorrecta: psycopg.connect(..., sslmode="require"). Correcta: sslmode="verify-full", sslrootcert="/etc/medinube/pki/ca.crt". Atac: un pod compromès a la xarxa interna fa ARP/DNS spoofing cap a pg.interna.medinube.example, presenta un certificat autosignat i el client amb require l'accepta sense rebufar: MalloryClinic acaba el TLS, llegeix credencials i consultes, i reenvia el trànsit a la BD real (MITM complet i invisible).

Conclusió

Ja no penses en primitives: penses en capes. Saps que "xifrat" sense cognoms no vol dir res, que cada capa (disc, BD, aplicació, còpia de seguretat) té el seu atacant, que l'app parla amb PostgreSQL amb verify-full contra la CA interna de 05-01, que cercar sobre dades xifrades es paga amb índexs cecs i honestedat sobre les seves fuites, i que l'historial d'Ana Pérez arriba xifrat fins a la fila de la base de dades amb DEK que viuen al KMS de 06-01. El mapa de dades de MediNube està complet.

Queda una dada viatgera que encara arrossega una vergonya del mòdul 1: el token de sessió del portal, aquell eyJ... que a 01-02 va resultar ser Base64 sense signatura — codificar no és xifrar... i tampoc és autenticar. A la propera lliçó el convertim en el que sempre hauria d'haver estat: un token signat. JWT, els seus atacs clàssics i com fer-lo servir bé. Ens veiem allà.

© Copyright 2026. Tots els drets reservats