La lección anterior dejó a inventario exigiendo un token firmado antes de mover una unidad de stock. Pero ese token, el teléfono de Ana en el pedido P-2026-000123, el evento pedido.creado que viaja por pedidos.eventos y las copias de seguridad de km0_inventario en MinIO siguen circulando y descansando en claro. Cualquiera con acceso a un segmento de red, a un disco reciclado, a un volumen de Docker o a un bucket mal configurado los lee o, peor, los modifica sin que nadie lo note. Saber quién es quién no sirve si la conversación se puede escuchar y alterar.

Esta lección trata de la criptografía aplicada a una plataforma distribuida: no cómo se diseñan los algoritmos, sino qué garantiza cada uno, cuándo usar cuál y cómo integrarlo sin cometer los errores que convierten un cifrado correcto en uno inútil. Veremos cifrado simétrico y asimétrico, hashes, HMAC y firmas; TLS para los datos en tránsito, con gRPC entre pedidos e inventario como caso práctico; cifrado en reposo en disco, base de datos y columna, con envelope encryption para el teléfono de Ana; cifrado del lado del servidor en MinIO y en los backups; y la protección de datos personales: minimización, seudonimización para el lago de datos y cifrado como base del derecho al olvido. Quedan para 06-04 la autenticación mutua con certificados (mTLS) y la custodia operativa de las claves (Vault).

Contenido

  1. Tres propiedades: confidencialidad, integridad, autenticidad
  2. Cifrado simétrico: AES-GCM, claves, nonces y etiquetas
  3. Cifrado asimétrico y cifrado híbrido
  4. Hashes, HMAC y firmas digitales
  5. Tabla resumen: qué primitiva para qué problema
  6. Datos en tránsito: TLS
  7. TLS en gRPC entre pedidos e inventario
  8. TLS en PostgreSQL, Kafka, Redis y MinIO
  9. Datos en reposo: disco, base de datos, columna
  10. Envelope encryption: el teléfono de Ana
  11. Gestión de claves: rotación, entornos y dónde no van
  12. Datos personales: minimización, seudonimización, anonimización y derecho al olvido
  13. Mapa de protección de Kilómetro Cero
  14. Errores Comunes y Consejos
  15. Ejercicios
  16. Conclusión

  1. Tres propiedades: confidencialidad, integridad, autenticidad

Cuando decimos "proteger un dato" mezclamos tres garantías distintas que se consiguen con herramientas distintas:

Propiedad Pregunta Amenaza Herramienta
Confidencialidad ¿Solo lo lee quien debe? Escucha en la red, disco robado, bucket público Cifrado (simétrico, asimétrico, TLS)
Integridad ¿Ha cambiado por el camino? Alteración de un evento, de un fichero, de un precio en tránsito Hash, MAC, firma; el tag de AES-GCM
Autenticidad ¿Lo produjo quien dice? Suplantación de pedidos en Kafka, servidor inventario falso MAC (con clave compartida), firma digital, certificado TLS

Cifrar no da integridad por sí solo: con algunos modos de cifrado clásicos (AES-CBC sin MAC) un atacante puede voltear bits del texto cifrado y producir cambios controlados en el texto en claro sin conocer la clave. Por eso la práctica moderna es el cifrado autenticado (AEAD), que combina ambas en una operación. Y a las tres se suele añadir una cuarta, el no repudio (que el autor no pueda negar la autoría), que solo la firma digital proporciona, porque el MAC lo pueden generar todos los que comparten la clave.

  1. Cifrado simétrico: AES-GCM, claves, nonces y etiquetas

En el cifrado simétrico la misma clave cifra y descifra. Es rápido (AES tiene instrucciones dedicadas en las CPU: varios GB/s por núcleo) y es lo que se usa para todo el volumen de datos: discos, ficheros, columnas, y el cuerpo de una conexión TLS. Su problema es la distribución de la clave: los dos extremos tienen que tenerla, y hacérsela llegar de forma segura es precisamente el problema que resuelve el cifrado asimétrico.

El estándar actual es AES-256-GCM (o ChaCha20-Poly1305 donde no hay aceleración AES). Tiene cinco piezas:

  • Clave: 32 bytes aleatorios. De su secreto depende todo.
  • Nonce (number used once): 12 bytes que no se repiten nunca con la misma clave. No es secreto: se guarda junto al texto cifrado. Su función es que cifrar dos veces el mismo mensaje dé resultados distintos.
  • Texto cifrado: del mismo tamaño que el original.
  • Etiqueta de autenticación (tag): 16 bytes que garantizan la integridad. Al descifrar, si un solo bit del cifrado (o de los datos asociados) cambió, la verificación falla y no se devuelve nada.
  • Datos asociados (AAD): información en claro que no se cifra pero sí se autentica: por ejemplo, el pedido_id al que pertenece un teléfono cifrado, para que nadie pueda copiar el teléfono cifrado de Ana al pedido de Marc.
# km0/servicios/comun/cripto.py
# pip install cryptography
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

def generar_clave() -> bytes:
    return AESGCM.generate_key(bit_length=256)          # 32 bytes de os.urandom

def cifrar(clave: bytes, texto: bytes, aad: bytes = b"") -> bytes:
    nonce = os.urandom(12)                              # NUEVO en cada cifrado
    cifrado_y_tag = AESGCM(clave).encrypt(nonce, texto, aad)
    return nonce + cifrado_y_tag                        # el nonce viaja delante, en claro

def descifrar(clave: bytes, blob: bytes, aad: bytes = b"") -> bytes:
    nonce, cifrado_y_tag = blob[:12], blob[12:]
    return AESGCM(clave).decrypt(nonce, cifrado_y_tag, aad)   # InvalidTag si algo cambió

if __name__ == "__main__":
    k = generar_clave()
    blob = cifrar(k, b"+34 600 123 456", aad=b"P-2026-000123")
    print(len(blob))                                    # 12 + 15 + 16 = 43 bytes
    print(descifrar(k, blob, aad=b"P-2026-000123"))     # b'+34 600 123 456'
    try:
        descifrar(k, blob, aad=b"P-2026-000124")        # otro pedido: el AAD no cuadra
    except Exception as e:
        print(type(e).__name__)                         # InvalidTag

Por qué nunca reutilizar un nonce. GCM genera internamente un flujo de bits (keystream) a partir de clave y nonce, y hace XOR con el mensaje. Si dos mensajes se cifran con el mismo par clave-nonce, el XOR de los dos textos cifrados es igual al XOR de los dos textos en claro: el atacante elimina el cifrado sin conocer la clave, y además puede recuperar la clave de autenticación y falsificar etiquetas. Con nonces aleatorios de 96 bits, la probabilidad de colisión se vuelve preocupante hacia los 2³² mensajes con la misma clave (unos 4 000 millones): más que suficiente para una clave por servicio y por año, pero es la razón por la que las claves se rotan y por la que un contador (nonce secuencial) es preferible cuando hay un único escritor que puede garantizar que no se repite.

  1. Cifrado asimétrico y cifrado híbrido

En el cifrado asimétrico hay un par de claves: lo que se cifra con la pública solo se descifra con la privada, y lo que se firma con la privada lo verifica cualquiera con la pública. Resuelve la distribución: inventario publica su clave pública y cualquiera puede enviarle un secreto que solo él abre. Dos familias:

Familia Ejemplos Tamaño de clave para ~128 bits de seguridad Uso
RSA RSA-2048, RSA-3072 2048-3072 bits Firmas (RS256 de 06-01), certificados; cifrado de claves pequeñas
Curvas elípticas ECDSA P-256, Ed25519 (firma), X25519 (intercambio de claves) 256 bits Firmas (ES256, EdDSA), intercambio de claves en TLS 1.3; más rápido y compacto

Lo que no se hace es cifrar datos voluminosos con RSA: es cientos de veces más lento que AES y solo puede cifrar mensajes más cortos que la clave. La solución universal es el cifrado híbrido: se genera una clave simétrica aleatoria para el mensaje, se cifra el mensaje con AES-GCM, y se cifra esa clave (32 bytes) con la clave pública del destinatario. TLS hace exactamente esto en cada conexión (apartado 6), y el envelope encryption del apartado 10 es el mismo patrón aplicado al almacenamiento.

  1. Hashes, HMAC y firmas digitales

Un hash criptográfico (SHA-256, SHA-3, BLAKE2) resume cualquier entrada en un valor fijo de forma que es inviable encontrar la entrada a partir del hash, o dos entradas con el mismo hash. Da integridad solo si el hash llega por un canal fiable: un atacante que puede cambiar el fichero puede cambiar el hash que lo acompaña. Por eso hay dos construcciones con clave:

  • HMAC (hash-based message authentication code): HMAC(clave, mensaje). Solo quien tiene la clave puede generarlo o verificarlo. Da integridad y autenticidad entre partes que comparten un secreto; no da no repudio (cualquiera de las partes pudo generarlo). Es lo que firma las URLs prefirmadas de MinIO (04-03) y los JWT HS256.
  • Firma digital (RSA-PSS, ECDSA, Ed25519): hash del mensaje cifrado con la clave privada. Cualquiera verifica con la pública; solo el firmante pudo producirla. Integridad, autenticidad y no repudio. Es RS256 en 06-01 y la base de los certificados TLS.

Un caso práctico de HMAC en Kilómetro Cero: la envoltura de eventos de 02-04. Hoy cualquier proceso con acceso a Kafka puede publicar un pedido.creado falso en pedidos.eventos, o modificar un evento reproducido desde el lago. Añadir un HMAC de la envoltura, con una clave que solo tienen los productores legítimos y los consumidores, permite a inventario y analitica descartar eventos manipulados:

# km0/servicios/comun/eventos_firmados.py
import hmac, hashlib, json

def _canonico(evento: dict) -> bytes:
    """Serialización determinista: mismas claves, mismo orden, sin espacios.
    Sin esto, dos JSON equivalentes darían HMAC distintos."""
    sin_firma = {k: v for k, v in evento.items() if k != "firma"}
    return json.dumps(sin_firma, sort_keys=True, separators=(",", ":"),
                      ensure_ascii=False).encode()

def firmar_evento(evento: dict, clave: bytes) -> dict:
    evento["firma"] = hmac.new(clave, _canonico(evento), hashlib.sha256).hexdigest()
    return evento

def verificar_evento(evento: dict, clave: bytes) -> bool:
    esperada = hmac.new(clave, _canonico(evento), hashlib.sha256).hexdigest()
    # compare_digest: tiempo constante, para no filtrar cuántos bytes coinciden
    return hmac.compare_digest(esperada, evento.get("firma", ""))

En el productor de pedidos (02-04), antes de producer.send, se llama a firmar_evento(evento, CLAVE_EVENTOS); en cada consumidor, if not verificar_evento(evento, CLAVE_EVENTOS): descartar_y_alertar(). La clave se distribuye con el gestor de secretos de 06-04. Cuando los consumidores no deban poder producir (por ejemplo, un tercero que lee reparto.panel), la envoltura se firma con Ed25519 en lugar de HMAC: mismo código con cryptography.hazmat.primitives.asymmetric.ed25519.

  1. Tabla resumen: qué primitiva para qué problema

Necesidad Primitiva Clave Ejemplo en Kilómetro Cero
Cifrar volumen de datos AES-256-GCM (AEAD) Simétrica Teléfono de Ana en km0_pedidos; volúmenes; backups
Enviar una clave a alguien / acordarla RSA-OAEP, X25519 (ECDH) Asimétrica Handshake TLS; envelope encryption con KMS
Detectar alteración con secreto compartido HMAC-SHA256 Simétrica Envoltura de pedidos.eventos; URLs prefirmadas
Detectar alteración sin secreto compartido, con autoría Firma Ed25519 / ECDSA / RSA-PSS Asimétrica JWT RS256 (06-01); certificados; eventos hacia terceros
Huella de un contenido SHA-256 Ninguna Deduplicar fotos en km0-fotos; ETag; hash encadenado de auditoría (06-05)
Almacenar contraseñas Argon2id / bcrypt Ninguna (sal) identidad (06-01)
Derivar claves de una maestra HKDF Simétrica Una clave por servicio y propósito a partir de una raíz

  1. Datos en tránsito: TLS

TLS (Transport Layer Security) es el protocolo que cifra y autentica una conexión TCP: HTTPS es HTTP sobre TLS, y gRPC, PostgreSQL, Kafka y Redis lo soportan de forma nativa. Combina todo lo anterior: cifrado asimétrico para acordar una clave, simétrico para el tráfico, firmas para autenticar al servidor, hashes para la integridad.

El handshake de TLS 1.3, resumido:

sequenceDiagram
    participant C as Cliente (pedidos)
    participant S as Servidor (inventario)
    C->>S: ClientHello: versiones, suites, clave pública efímera (X25519)
    S->>C: ServerHello: suite elegida, clave pública efímera
    Note over C,S: Ambos derivan la misma clave simétrica (ECDH).<br/>A partir de aquí todo va cifrado.
    S->>C: Certificate (cadena) + CertificateVerify (firma con la privada) + Finished
    C->>C: Verifica cadena hasta una CA de confianza,<br/>que el nombre coincide (SAN), fechas, revocación
    C->>S: Finished
    C->>S: Datos de aplicación (gRPC) cifrados con AES-GCM / ChaCha20

Conceptos que conviene fijar:

  • Certificado: la clave pública del servidor más su identidad (nombre DNS en el campo Subject Alternative Name, SAN), firmadas por una autoridad de certificación (CA). El cliente confía en la CA (tiene su certificado raíz en su almacén de confianza) y, por transitividad, en el servidor.
  • Cadena de confianza: raíz → intermedia → servidor. La raíz se guarda fuera de línea; las intermedias firman los certificados de los servidores. En Internet, las raíces las distribuyen los sistemas operativos y navegadores; dentro de Kilómetro Cero, la CA será propia (esta lección la crea con OpenSSL; 06-04 la convierte en una PKI con emisión automática y certificados para clientes).
  • Secreto perfecto hacia delante (forward secrecy): en TLS 1.3 la clave de sesión se acuerda con claves efímeras que se destruyen; robar la clave privada del servidor mañana no permite descifrar el tráfico capturado hoy.
  • TLS 1.3 (2018) eliminó las suites débiles, redujo el handshake a un viaje de ida y vuelta y cifra el certificado del servidor. TLS 1.0 y 1.1 están obsoletos; 1.2 se acepta si está bien configurado. Configura siempre mínimo 1.2, preferido 1.3.
  • Terminación: la conexión TLS puede acabar en el propio servicio o en un componente delante de él (el gateway de 06-05 terminará el TLS de Internet). Entre el terminador y el servicio, si hay red, hace falta TLS otra vez.

Lo que TLS no hace por defecto: autenticar al cliente. inventario sabrá que habla con cifrado, y pedidos sabrá que habla con el verdadero inventario, pero inventario no sabe si el cliente es pedidos o un proceso cualquiera. Eso es mTLS, y es la lección 06-04.

  1. TLS en gRPC entre pedidos e inventario

Vamos a sustituir el add_insecure_port que 02-03 dejó "sin TLS por ahora". Primero, una CA interna y un certificado de servidor con OpenSSL:

# km0/certs/generar.sh  (desarrollo; en producción lo hará la PKI de 06-04)
set -e
mkdir -p km0/certs && cd km0/certs

# 1. CA raíz de Kilómetro Cero (clave EC P-256, válida 10 años, solo en desarrollo)
openssl ecparam -name prime256v1 -genkey -noout -out km0-ca.key
openssl req -x509 -new -key km0-ca.key -sha256 -days 3650 \
    -subj "/CN=Kilometro Cero CA interna" -out km0-ca.crt

# 2. Clave y CSR para inventario, con SAN (nombre DNS en Compose y localhost para pruebas)
openssl ecparam -name prime256v1 -genkey -noout -out inventario.key
openssl req -new -key inventario.key -subj "/CN=inventario" -out inventario.csr

cat > inventario.ext <<EOF
subjectAltName = DNS:inventario, DNS:inventario.km0.internal, DNS:localhost
extendedKeyUsage = serverAuth
EOF

# 3. La CA firma el certificado de inventario (90 días: los certificados cortos se rotan, no se vigilan)
openssl x509 -req -in inventario.csr -CA km0-ca.crt -CAkey km0-ca.key -CAcreateserial \
    -days 90 -sha256 -extfile inventario.ext -out inventario.crt

openssl x509 -in inventario.crt -noout -subject -dates -ext subjectAltName
rm inventario.csr inventario.ext

El detalle que más fallos produce es el SAN: el cliente compara el nombre al que se conectó (inventario, el nombre del servicio en Compose) con los del certificado; si no coincide, rechaza la conexión aunque la firma sea válida. El CN ya no cuenta para esa comprobación.

Servidor (servicios/inventario/servidor.py), solo cambia el arranque:

import grpc
from concurrent import futures

def _leer(ruta):
    with open(ruta, "rb") as f:
        return f.read()

credenciales = grpc.ssl_server_credentials(
    private_key_certificate_chain_pairs=[(_leer("certs/inventario.key"),
                                          _leer("certs/inventario.crt"))],
    # root_certificates y require_client_auth llegan en 06-04 (mTLS)
)
servidor = grpc.server(futures.ThreadPoolExecutor(max_workers=10),
                       interceptors=[AuthInterceptor()])          # el de 06-01 sigue ahí
inventario_pb2_grpc.add_InventarioServicer_to_server(InventarioServicer(), servidor)
servidor.add_secure_port("0.0.0.0:50051", credenciales)          # antes: add_insecure_port

Cliente en pedidos:

# km0/servicios/pedidos/cliente_inventario.py (fragmento)
credenciales = grpc.ssl_channel_credentials(root_certificates=_leer("certs/km0-ca.crt"))
canal = grpc.secure_channel("inventario:50051", credenciales)   # el nombre debe estar en el SAN
stub = inventario_pb2_grpc.InventarioStub(canal)
# El JWT sigue viajando en metadatos, ahora cifrado por TLS:
stub.ReservarStock(req, metadata=(("authorization", f"Bearer {token}"),), timeout=2)

Solo se le da al cliente la CA, no el certificado de inventario: así, cuando el certificado de inventario se renueve dentro de 90 días, pedidos seguirá confiando sin cambiar nada. Si pedidos se conecta por localhost:50051 desde el host, funciona porque localhost está en el SAN; si se conectara por IP, fallaría (Ssl handshake failed ... Hostname mismatch).

En docker-compose.yml, inventario y pedidos montan ./certs en solo lectura; el fichero km0-ca.key no se monta en ningún servicio (solo se necesita para firmar):

  inventario:
    build: ./servicios/inventario
    volumes:
      - ./certs/inventario.crt:/app/certs/inventario.crt:ro
      - ./certs/inventario.key:/app/certs/inventario.key:ro
  pedidos:
    build: ./servicios/pedidos
    volumes:
      - ./certs/km0-ca.crt:/app/certs/km0-ca.crt:ro

Puedes comprobar que el canal va cifrado capturando el tráfico: docker compose exec pedidos tcpdump -A port 50051 mostraba antes queso-curado y el JWT en claro; ahora solo bytes ilegibles tras el ClientHello.

  1. TLS en PostgreSQL, Kafka, Redis y MinIO

Cada almacén de los Módulos 2 y 4 tiene su forma de activar TLS. No la desarrollamos aquí, pero sí la dejamos localizada, porque todos los clientes de Kilómetro Cero tendrán que cambiar su cadena de conexión:

Sistema Lado servidor Lado cliente (Python) Nota
PostgreSQL (km0_analitica, km0_inventario) ssl = on, ssl_cert_file, ssl_key_file en postgresql.conf; hostssl en pg_hba.conf para obligar psycopg.connect(..., sslmode="verify-full", sslrootcert="certs/km0-ca.crt") sslmode=require cifra pero no verifica el servidor; verify-full sí
Kafka (pedidos.eventos, reparto.posiciones...) Listener SSL://:9093, ssl.keystore.location, ssl.truststore.location; SASL_SSL si además hay autenticación KafkaProducer(security_protocol="SSL", ssl_cafile="certs/km0-ca.crt") Kafka usa keystores JKS/PKCS12; conviértelos con keytool/openssl pkcs12
Redis (caché, sesiones, revocados) tls-port 6379, port 0, tls-cert-file, tls-key-file, tls-ca-cert-file redis.Redis(ssl=True, ssl_ca_certs="certs/km0-ca.crt") o rediss:// Redis ≥ 6; los clientes de Redis Cluster de 04-05 también lo soportan
Cassandra (km0_pedidos) client_encryption_options: enabled: true en cassandra.yaml Cluster(ssl_context=ctx) con un ssl.SSLContext cargando la CA La comunicación entre nodos se cifra aparte (server_encryption_options)
MinIO (km0-fotos...) Certificados en ~/.minio/certs/public.crt y private.key boto3.client("s3", endpoint_url="https://minio:9000", verify="certs/km0-ca.crt") Las URLs prefirmadas de 04-03 pasan a https:// sin más cambios
HDFS (/km0/eventos/...) dfs.http.policy=HTTPS_ONLY, dfs.encrypt.data.transfer=true WebHDFS por https:// Kerberos para autenticación es un mundo aparte, fuera del alcance del curso

La regla común: verificar siempre el certificado del servidor contra la CA interna. Un cliente que cifra sin verificar (sslmode=require, verify=False) protege contra escuchas pasivas pero no contra un servidor impostor, que es el ataque de "hombre en el medio" que TLS existe para impedir.

  1. Datos en reposo: disco, base de datos, columna

Los datos pasan mucho más tiempo en reposo que en tránsito, y allí las amenazas son otras: un disco retirado sin borrar, un volumen de nube clonado, una copia de seguridad en un bucket accesible, un administrador de base de datos curioso, o un SELECT * de un servicio comprometido. Hay tres niveles, que se acumulan:

Nivel Qué protege Contra qué Contra qué no Coste
Disco / volumen (LUKS, dm-crypt, cifrado de volúmenes EBS/PD) Todo lo que está en el disco Disco robado o reciclado, volumen clonado, acceso físico Cualquiera con acceso al sistema en marcha: la base de datos ve los datos en claro Casi nulo (AES-NI); transparente
Base de datos (TDE: transparent data encryption, cifrado de ficheros de datos y WAL) Los ficheros de la BD y sus backups Ficheros copiados, backups filtrados Administradores de la BD, cualquier SELECT autorizado Bajo; requiere gestionar la clave fuera de la BD
Columna / campo (la aplicación cifra antes de INSERT) Campos concretos: teléfono, dirección, IBAN DBA, dumps, un servicio comprometido que lee la tabla, filtraciones de replicas y analítica Un compromiso del servicio que posee la clave Alto: no se puede indexar ni buscar por el campo; hay que gestionar claves por servicio

Kilómetro Cero aplica los tres: volúmenes cifrados en toda la infraestructura (una línea de configuración en la nube o en la instalación), TDE donde el motor lo ofrezca (Cassandra y PostgreSQL con extensiones o cifrado de ficheros), y cifrado de campo para los datos personales sensibles que solo un servicio necesita en claro. El teléfono de Ana lo necesita reparto para que el repartidor llame al llegar; analitica jamás debería verlo. Con cifrado de campo y clave propia de reparto, aunque analitica lea la tabla, ve bytes.

  1. Envelope encryption: el teléfono de Ana

Si cada servicio cifra sus campos con una clave, ¿dónde vive esa clave? Guardarla junto a los datos anula el cifrado; guardarla en cada proceso hace la rotación imposible (habría que recifrar toda la tabla). El patrón estándar es el cifrado por sobres (envelope encryption):

  1. Existe una clave maestra (KEK, key encryption key) que nunca sale de un KMS (key management service): AWS KMS, Google Cloud KMS, Azure Key Vault, o el motor transit de HashiCorp Vault. El KMS ofrece dos operaciones: cifrar(bytes) y descifrar(bytes), con control de acceso y auditoría.
  2. Para cada dato (o cada lote, o cada cliente) se genera una clave de datos (DEK) aleatoria, se cifra el dato con AES-GCM y la DEK, y se cifra la DEK con la KEK en el KMS.
  3. Se guardan juntos el dato cifrado y la DEK cifrada. La DEK en claro se descarta.
  4. Para leer: se pide al KMS que descifre la DEK, y con ella se descifra el dato.
flowchart LR
    T[Teléfono de Ana<br/>+34 600 123 456] -->|AES-GCM con DEK| TC[Teléfono cifrado]
    DEK[DEK aleatoria<br/>32 bytes] -->|KMS.cifrar con KEK| DEKC[DEK cifrada]
    TC --> BD[(km0_pedidos<br/>telefono_cif, dek_cif, kek_id)]
    DEKC --> BD
    KMS[KMS / Vault transit<br/>KEK 'km0-pedidos-2026' nunca sale]
    DEK -.generada localmente.-> DEK

La ventaja decisiva: rotar la KEK no obliga a recifrar los datos, solo a recifrar las DEK (32 bytes cada una, y puede hacerse perezosamente), y revocar el acceso de un servicio a la KEK deja todos sus datos ilegibles de golpe, aunque tenga la tabla entera. En Kilómetro Cero no tenemos un KMS de nube; en 06-04 Vault ocupará ese papel con su motor transit. Para esta lección, simulamos el KMS con una clase que expone solo cifrar_dek/descifrar_dek y guarda la KEK fuera del alcance del código de negocio:

# km0/servicios/pedidos/cifrado_campos.py
import os, base64, json
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

class KMSLocal:
    """Sustituto de desarrollo de un KMS: la KEK vive aquí y nunca se expone.
    En 06-04 esta clase se reemplaza por el motor 'transit' de Vault."""
    def __init__(self, kek_por_id: dict[str, bytes], kek_activa: str):
        self._keks = kek_por_id
        self.kek_activa = kek_activa

    def cifrar_dek(self, dek: bytes) -> tuple[str, bytes]:
        nonce = os.urandom(12)
        return self.kek_activa, nonce + AESGCM(self._keks[self.kek_activa]).encrypt(nonce, dek, b"dek")

    def descifrar_dek(self, kek_id: str, dek_cifrada: bytes) -> bytes:
        kek = self._keks[kek_id]                  # KeyError si la KEK fue retirada: crypto-shredding
        return AESGCM(kek).decrypt(dek_cifrada[:12], dek_cifrada[12:], b"dek")


class CifradorCampos:
    """Cifra campos de un registro con envelope encryption y AAD = id del registro."""
    def __init__(self, kms: KMSLocal):
        self._kms = kms

    def cifrar(self, valor: str, registro_id: str) -> str:
        dek = AESGCM.generate_key(bit_length=256)
        nonce = os.urandom(12)
        cifrado = AESGCM(dek).encrypt(nonce, valor.encode(), registro_id.encode())
        kek_id, dek_cifrada = self._kms.cifrar_dek(dek)
        sobre = {"v": 1, "kek": kek_id,
                 "dek": base64.b64encode(dek_cifrada).decode(),
                 "n": base64.b64encode(nonce).decode(),
                 "c": base64.b64encode(cifrado).decode()}
        return json.dumps(sobre, separators=(",", ":"))      # se guarda como TEXT/BLOB

    def descifrar(self, sobre_json: str, registro_id: str) -> str:
        s = json.loads(sobre_json)
        dek = self._kms.descifrar_dek(s["kek"], base64.b64decode(s["dek"]))
        return AESGCM(dek).decrypt(base64.b64decode(s["n"]), base64.b64decode(s["c"]),
                                   registro_id.encode()).decode()


if __name__ == "__main__":
    # En desarrollo, la KEK viene de una variable de entorno cargada desde el gestor de secretos (06-04)
    kms = KMSLocal({"km0-pedidos-2026": base64.b64decode(os.environ["KM0_KEK_PEDIDOS"])},
                   kek_activa="km0-pedidos-2026")
    cif = CifradorCampos(kms)
    sobre = cif.cifrar("+34 600 123 456", "P-2026-000123")
    print(sobre[:80], "...")
    print(cif.descifrar(sobre, "P-2026-000123"))       # +34 600 123 456
    # Copiar el sobre al pedido de Marc no sirve: el AAD lo delata
    try:
        cif.descifrar(sobre, "P-2026-000124")
    except Exception as e:
        print("Rechazado:", type(e).__name__)           # InvalidTag

En Cassandra (km0_pedidos), la columna telefono pasa a ser telefono_cif text, y en servicios/pedidos solo el código que atiende a reparto llama a descifrar. Los requisitos de consulta cambian: no se puede hacer WHERE telefono = ... sobre un campo cifrado con nonce aleatorio. Si hace falta buscar por teléfono (por ejemplo, para que soporte localice un pedido), se añade una columna telefono_hmac con HMAC(clave_indice, telefono normalizado): determinista, buscable, y no reversible. Es el mismo truco de la seudonimización del apartado 12.

Para campos de pagos, la regla es no tenerlos: el número de tarjeta (PAN) nunca llega a Kilómetro Cero; el navegador lo entrega directamente a la pasarela de pago, que devuelve un token de pago que pagos almacena. Cifrar un PAN propio es asumir PCI DSS completo; delegar es la única opción sensata para un marketplace.

  1. Gestión de claves: rotación, entornos y dónde no van

El cifrado desplaza el problema: de proteger datos a proteger claves, que son pocas y pequeñas. Principios:

  • Nunca en el código ni en el repositorio. Ni en un .py, ni en un docker-compose.yml versionado, ni en una imagen de contenedor. Un secreto en Git lo es para siempre en el historial. La variable de entorno del ejemplo anterior es un paso intermedio que 06-04 sustituye por inyección desde Vault.
  • Una clave por entorno: desarrollo, pruebas y producción no comparten ninguna clave. Un volcado de la base de pruebas jamás debe ser descifrable con la clave de producción.
  • Una clave por servicio y por propósito: pedidos no puede descifrar los campos de reparto, y la clave de HMAC de eventos no es la de cifrado de campos. Con HKDF se derivan varias de una raíz si es necesario, pero las KEK de producción viven en el KMS.
  • Rotación programada: la KEK anualmente (o antes si hay sospecha), las DEK por registro (no hace falta rotarlas: se regeneran al reescribir), los certificados TLS cada 90 días o menos, la clave de firma de JWT cada pocos meses con kid. Todo cifrado guarda el identificador de la clave con que se hizo, para poder descifrar durante la transición.
  • Separación de funciones: quien administra la base de datos no administra el KMS; quien despliega no ve las claves de producción.
  • Copia de seguridad de las claves, cifradas y en otro lugar que los datos: unos datos cifrados cuya clave se ha perdido son datos borrados.

Lo que aquí es principio, en 06-04 será operación: Vault, credenciales dinámicas, leases y auditoría de accesos a cada clave.

  1. Datos personales: minimización, seudonimización, anonimización y derecho al olvido

Ana, Marc y Lucía son personas, y sus nombres, direcciones, teléfonos, correos, historial de compras y (en reparto.posiciones) la posición de los repartidores son datos personales sujetos al RGPD. La criptografía es una de las herramientas, no la única, y las decisiones de diseño importan más que los algoritmos:

  • Minimización: no recoger ni conservar lo que no se necesita. El lago de datos de 04-02 guardaba en /km0/eventos/<día>/pedidos.jsonl el evento completo, con email y dirección. analitica necesita saber que un mismo cliente compró tres veces, no quién es. Y ventas_diarias no necesita ni eso.
  • Seudonimización: sustituir los identificadores directos por un seudónimo estable que solo puede revertirse con información guardada aparte. cliente_id en lugar de email es el mínimo; mejor aún, un HMAC del cliente_id con una clave que analitica no tiene, de modo que ni siquiera un JOIN accidental con km0_pedidos reidentifique. Los datos seudonimizados siguen siendo personales, pero el riesgo de una fuga del lago baja radicalmente.
  • Anonimización: transformar los datos de forma que la reidentificación sea razonablemente imposible: agregar (ventas por producto y día, no por cliente), generalizar (ciudad en lugar de dirección; franja horaria en lugar de instante), suprimir valores raros (un único pedido de vino-crianza en Lleida un martes identifica a alguien aunque no haya nombre). Los agregados de km0_analitica son anónimos; los eventos seudonimizados no.
  • Tokenización: reemplazar un valor por un token sin relación matemática con él, con una bóveda que guarda la correspondencia; es lo que hace la pasarela de pago con la tarjeta.
  • Derecho de supresión ("al olvido"): cuando Lucía pide que se borren sus datos, hay que borrarlos de km0_pedidos, de las réplicas, de los backups, del lago y de los tópicos de Kafka con retención larga. Borrar de un backup inmutable o de un log de Kafka es impracticable. El crypto-shredding lo resuelve: si los datos personales de Lucía se cifraron con una DEK propia de Lucía (una por cliente, guardada cifrada por la KEK), destruir esa DEK convierte todos sus datos, en todos los sitios, en ruido irrecuperable, sin tocar los backups. Es la razón de peso para elegir "una DEK por cliente" en lugar de "una por registro" en el apartado 10.

La seudonimización de eventos para el lago, en código:

# km0/servicios/analitica/seudonimizar.py
# Se ejecuta en el consumidor que escribe /km0/eventos/<día>/pedidos.jsonl (04-02),
# de modo que el lago nunca reciba identificadores directos.
import hmac, hashlib

CAMPOS_DIRECTOS = {"email", "nombre", "telefono", "direccion"}       # se eliminan
CAMPOS_SEUDONIMO = {"cliente_id"}                                    # se sustituyen

def seudonimizar(evento: dict, clave_seudonimos: bytes) -> dict:
    datos = dict(evento["datos"])
    for campo in CAMPOS_DIRECTOS:
        datos.pop(campo, None)
    for campo in CAMPOS_SEUDONIMO:
        if campo in datos:
            datos[campo] = hmac.new(clave_seudonimos, datos[campo].encode(),
                                    hashlib.sha256).hexdigest()[:24]
    if "direccion_entrega" in datos:                # generalizar: solo el mercado
        datos["mercado"] = datos.pop("direccion_entrega").get("mercado")   # 'girona'
    return {**evento, "datos": datos}

# Entrada:  {"tipo": "pedido.creado", "datos": {"pedido_id": "P-2026-000125", "cliente_id": "u-lucia",
#            "email": "[email protected]", "telefono": "+34 6...", "lineas": [...],
#            "direccion_entrega": {"calle": "...", "mercado": "lleida"}}}
# Salida:   {"tipo": "pedido.creado", "datos": {"pedido_id": "P-2026-000125",
#            "cliente_id": "9f1c2a...b7", "lineas": [...], "mercado": "lleida"}}

clave_seudonimos la custodia identidad (o el KMS), no analitica: así el equipo de datos puede contar clientes recurrentes y calcular recomendaciones (05-03) sin poder saber quién es 9f1c2a...b7. Si un día se necesita contactar con ese cliente (por ejemplo, para una retirada de producto), la petición pasa por identidad, que sí puede recalcular el HMAC de cada cliente y encontrar la correspondencia, con auditoría de quién lo pidió (06-05).

Advertencia de cumplimiento. El RGPD (y la LOPDGDD en España) exige mucho más que cifrado: base jurídica, información al interesado, registro de actividades de tratamiento, evaluación de impacto cuando hay seguimiento de posiciones, contratos con encargados (la pasarela de pago, el proveedor de nube), y notificación de brechas en 72 horas. PCI DSS regula cualquier contacto con datos de tarjeta. Lo aquí descrito es el diseño técnico; la política de retención, el alcance de la seudonimización y el procedimiento de supresión deben definirse y revisarse con un profesional de protección de datos y seguridad.

  1. Mapa de protección de Kilómetro Cero

Con todo lo anterior, cada dato de la plataforma queda localizado y protegido:

Dato Dónde vive En tránsito En reposo Personal
Contraseñas identidad (PostgreSQL) TLS Argon2id (irreversible) Sí
JWT de acceso Memoria de la app; metadatos gRPC / Authorization TLS (gRPC y HTTP) No se persiste Contiene sub
Catálogo, precios, fotos catalogo (PostgreSQL), Redis, km0-fotos TLS; URLs prefirmadas por HTTPS Volumen cifrado; SSE en MinIO No
Pedidos (líneas, importes) km0_pedidos (Cassandra) TLS cliente-nodo y entre nodos Volumen cifrado + TDE Sí (vinculados a cliente_id)
Teléfono y dirección de entrega km0_pedidos TLS Cifrado de campo (envelope, DEK por cliente, KEK en KMS) Sí, sensible
Datos de tarjeta No se almacenan: token de la pasarela en pagos TLS navegador → pasarela Token opaco PCI DSS delegado
Stock km0_inventario (inv-bcn, inv-vlc) TLS Volumen cifrado No
Eventos (pedidos.eventos, inventario.alertas) Kafka TLS + HMAC de la envoltura Volumen cifrado; retención limitada Sí hasta seudonimizar
Posiciones (reparto.posiciones, reparto.panel) Kafka, Flink TLS + HMAC Retención corta (horas) Sí (repartidores): minimizar y agregar
Lago de eventos HDFS /km0/eventos/... HTTPS / transferencia cifrada Volumen cifrado; seudonimizado al escribir Seudónimos
Agregados km0_analitica (PostgreSQL) TLS Volumen cifrado Anónimos
Facturas PDF km0-facturas (MinIO) HTTPS prefirmado SSE-S3 con clave de MinIO + object lock Sí
Backups km0-backups (MinIO) HTTPS Cifrados por el proceso de backup (AES-GCM, clave del KMS) antes de subir, además de SSE Sí
Configuración de etcd etcd TLS entre pares y clientes Volumen cifrado; sin secretos dentro (06-04) No

Sobre el cifrado del lado del servidor en MinIO/S3 (SSE): el almacén cifra cada objeto al escribirlo y lo descifra al servirlo. SSE-S3 (clave gestionada por el almacén) protege contra discos y volúmenes; SSE-KMS (clave en el KMS, con auditoría por acceso) protege además contra administradores del almacén; SSE-C (el cliente envía la clave en cada petición) deja la custodia al cliente. Para km0-fotos basta SSE-S3; para km0-facturas y km0-backups, SSE-KMS y, en el caso de los backups, cifrado adicional en el cliente para que un compromiso de MinIO no los exponga.

Errores Comunes y Consejos

  • Cifrar sin autenticar. AES-CBC o AES-CTR "a pelo" permiten manipular el texto cifrado. Usa siempre un modo AEAD (GCM, ChaCha20-Poly1305) o cifrado + HMAC (encrypt-then-MAC).
  • Reutilizar nonces. Un nonce fijo, o derivado de algo repetible (el id del pedido), destruye AES-GCM. os.urandom(12) por cifrado, o un contador que se persiste.
  • Inventar criptografía. Ni algoritmos propios ni combinaciones creativas de primitivas. cryptography con su API de alto nivel (AESGCM, Fernet) y protocolos estándar (TLS) son el límite de lo que un equipo de producto debe tocar.
  • verify=False en desarrollo que llega a producción. Un cliente TLS que no verifica el certificado es una escucha pasiva de más. Usa la CA interna también en desarrollo.
  • Certificado sin SAN correcto. "Hostname mismatch" es el error de TLS más frecuente; el SAN debe contener todos los nombres por los que se accede al servicio.
  • Guardar la clave junto a los datos (en la misma base de datos, en el mismo bucket, en el docker-compose.yml). Envelope encryption con la KEK fuera, en el KMS.
  • Cifrar un campo y luego buscar por él. No es posible con nonce aleatorio; planifica una columna HMAC determinista si necesitas buscar.
  • Comparar HMAC con ==. La comparación normal se detiene en el primer byte distinto y filtra información por tiempo; hmac.compare_digest siempre.
  • Confundir seudonimizar con anonimizar. Sustituir el email por un hash sigue siendo dato personal; solo la agregación o generalización suficiente anonimiza.
  • Backups en claro "porque el bucket es privado". Los buckets se hacen públicos por error con una frecuencia asombrosa. Cifra antes de subir.
  • Consejo: guarda con cada dato cifrado el identificador de la clave y la versión del formato ({"v":1,"kek":"km0-pedidos-2026",...}). Sin eso, la primera rotación de clave es una migración a ciegas.
  • Consejo: prueba la recuperación: un backup cifrado que nunca se ha restaurado con la clave de otro entorno es un backup que no existe.

Ejercicios

Ejercicio 1: Un evento manipulado

Un proceso desconocido dentro de la red de Kilómetro Cero publica en pedidos.eventos un evento pedido.creado para P-2026-000126 con una línea de 200 unidades de vino-crianza a 0,01 €. Explica qué ocurre en inventario y analitica (a) con el diseño de 02-04 sin cambios, (b) con TLS en Kafka pero sin HMAC, (c) con el HMAC del apartado 4. Después, indica qué protege el HMAC que TLS no protege, y viceversa, y por qué el HMAC no impide que el proceso siga publicando eventos basura en el tópico (qué lección resuelve eso).

Ejercicio 2: Lucía ejerce su derecho de supresión

Lucía pide que se borren sus datos. Enumera dónde están (usa la tabla del apartado 13) y, para cada lugar, si se puede borrar directamente, si hace falta crypto-shredding, o si el dato ya no es personal. Explica qué habría que haber decidido en el apartado 10 para que el crypto-shredding funcione, y qué queda en km0_analitica después.

Ejercicio 3: Rotar la KEK

El equipo de seguridad decide rotar km0-pedidos-2026 a km0-pedidos-2027. Describe paso a paso, usando las clases KMSLocal y CifradorCampos, qué hay que cambiar para que (1) los datos nuevos se cifren con la nueva KEK, (2) los antiguos sigan leyéndose, (3) los antiguos se migren a la nueva sin recifrar los teléfonos, y (4) la antigua se retire. ¿Qué campo del sobre hace posible el paso 2? ¿Cuántos bytes hay que recifrar por registro en el paso 3?

Soluciones

Ejercicio 1.

(a) El evento llega, inventario lo consume y reserva 200 unidades de vino-crianza del stock de la Bodega Roble Alto (o agota el stock y dispara inventario.alertas), analitica lo escribe en el lago y ventas_diarias contabiliza 2 € de ventas de vino; nadie detecta nada porque el evento es sintácticamente correcto. (b) TLS cifra el canal entre cada cliente y el broker y autentica al broker, pero no al productor: el proceso desconocido abre su propia conexión TLS válida y publica igual; solo protege contra alguien que escuche o modifique el tráfico de otro cliente. (c) Ambos consumidores calculan HMAC(clave, envoltura canónica), no coincide con el campo firma (el atacante no tiene la clave) y descartan el evento con una alerta; el stock no se toca y el lago no lo recibe. El HMAC protege la integridad y autenticidad del mensaje de extremo a extremo, incluso frente al broker, a un reproductor desde el lago o a un productor no autorizado; TLS protege la conexión (confidencialidad y que el broker es el auténtico), cosa que el HMAC no hace (el evento sigue siendo legible en el tópico). El HMAC no impide publicar porque Kafka no exige que el productor se identifique: eso requiere autenticación del cliente en el broker (SASL o certificados de cliente, es decir, mTLS y ACLs), que es la identidad de carga de trabajo de 06-04.

Ejercicio 2.

identidad (cuenta, hash de contraseña): borrado directo. km0_pedidos: líneas e importes vinculados a cliente_id: pueden borrarse o disociarse (sustituir cliente_id por un marcador), según lo que exija la obligación fiscal de conservar facturas; teléfono y dirección cifrados: crypto-shredding destruyendo la DEK de Lucía, lo que también invalida las copias en réplicas y en los backups de km0-backups, donde el borrado directo es impracticable. km0-facturas: las facturas tienen retención legal de años con object lock; no se borran, se justifica la conservación por obligación legal (y sus datos de contacto pueden estar cifrados con la misma DEK). Kafka pedidos.eventos: los eventos con datos directos dentro de la retención no se pueden editar; si la retención es corta expiran solos; si es larga, es un motivo más para que los eventos lleven los campos personales cifrados con la DEK del cliente o seudonimizados. Lago HDFS: solo contiene el seudónimo HMAC; sigue siendo dato personal mientras identidad conserve la clave y el cliente_id; al borrar la cuenta en identidad, el seudónimo deja de ser vinculable y puede argumentarse que queda anonimizado (a revisar con el profesional de protección de datos). km0_analitica: agregados anónimos, no se toca. La decisión necesaria en el apartado 10 es una DEK por cliente (no por registro), guardada cifrada por la KEK en un lugar central: destruir esa única DEK es el borrado.

Ejercicio 3.

(1) Añadir "km0-pedidos-2027": nueva_kek al diccionario de KMSLocal y poner kek_activa = "km0-pedidos-2027": cifrar_dek usará la nueva para todo sobre nuevo. (2) Nada más: descifrar_dek recibe s["kek"] del sobre y busca esa KEK en el diccionario, que sigue conteniendo la 2026. El campo "kek" del sobre es lo que lo hace posible. (3) Un proceso de migración que, para cada registro, hace dek = kms.descifrar_dek(s["kek"], s["dek"]) y s["kek"], s["dek"] = kms.cifrar_dek(dek), y guarda el sobre; el teléfono cifrado ("c") y su nonce no se tocan. Se recifran 32 bytes (la DEK) más los 12 del nonce y 16 del tag del sobre de la DEK: unos 60 bytes por registro, frente a recifrar el campo entero y, sobre todo, sin necesidad de que el proceso de migración vea los teléfonos en claro. (4) Cuando ningún sobre en la base de datos ni en los backups que se quieran mantener legibles tenga "kek": "km0-pedidos-2026", eliminar la entrada del diccionario (en Vault: deshabilitar la versión). Cualquier sobre olvidado dará KeyError, que es exactamente el comportamiento de crypto-shredding: por eso se verifica con una consulta antes de retirar.

Conclusión

Proteger un dato es garantizar tres cosas distintas, confidencialidad, integridad y autenticidad, y cada una tiene su herramienta: AES-GCM para cifrar volumen con nonce único y etiqueta de autenticación; RSA y curvas elípticas para acordar claves y firmar, siempre en modo híbrido; hashes para huellas, HMAC para integridad con secreto compartido, firmas para autoría verificable por todos. TLS reúne todas ellas para el tránsito, con certificados, cadena de confianza, SAN y TLS 1.3, y Kilómetro Cero lo tiene ahora entre pedidos e inventario con una CA interna, y localizado en la configuración de PostgreSQL, Kafka, Redis, Cassandra, MinIO y HDFS. En reposo, el cifrado se acumula por capas (volumen, base de datos, campo) y el envelope encryption con una KEK en un KMS hace posible rotar claves, revocar accesos y borrar por destrucción de clave. Los datos personales de Ana, Marc y Lucía se minimizan, se seudonimizan antes de entrar en el lago, se agregan para analítica, y se cifran con una DEK por cliente que convierte el derecho de supresión en una operación de 32 bytes. Los datos de tarjeta, sencillamente, no entran. Y todo ello bajo la revisión de un profesional de cumplimiento, porque el RGPD y PCI DSS son bastante más que criptografía.

Queda una pieza que esta lección ha dado por resuelta con un fichero .pub copiado a mano y un formulario de login propio: de dónde salen las identidades. Los clientes se registran en la web, los empleados tienen cuentas corporativas, los productores quieren entrar con su cuenta de Google, la app de repartidores necesita un flujo sin contraseña visible, y pedidos necesita un token de máquina para hablar con inventario. Gestionar todo eso servicio por servicio es inviable. La siguiente lección centraliza la identidad en un proveedor (Keycloak), conecta a los empleados con LDAP, y explica los protocolos que hacen que un token emitido allí valga en toda la plataforma: SAML, OAuth 2.0 y OpenID Connect.

Curso de Arquitecturas Distribuidas

Módulo 1: Introducción a los Sistemas Distribuidos

Módulo 2: Comunicación en Sistemas Distribuidos

Módulo 3: Consistencia y Replicación

Módulo 4: Almacenamiento Distribuido

Módulo 5: Computación Distribuida

Módulo 6: Seguridad en Sistemas Distribuidos

Módulo 7: Monitoreo y Mantenimiento

Módulo 8: Casos de Estudio y Aplicaciones

© Copyright 2026. Todos los derechos reservados