La fijación de certificados (certificate pinning) hace que un cliente acepte una conexión TLS solo si el servidor presenta una clave pública concreta, además de una cadena válida. Protege frente a certificados emitidos de forma fraudulenta por cualquier CA, pero si se gestiona mal deja a los clientes sin poder conectar. En esta guía calcularás pines de clave pública con OpenSSL en Ubuntu 24.04, los aplicarás en curl y en una aplicación Android, y prepararás claves de respaldo y renovaciones que no rompan los clientes.

Requisitos previos

Para seguir esta guía necesitas:

  • Un servidor con Ubuntu 24.04 LTS que sirva HTTPS para your_domain (por ejemplo un VPS de CubePath con Nginx y un certificado de Let's Encrypt emitido con Certbot) y un usuario con privilegios sudo.
  • OpenSSL y curl, instalados por defecto en Ubuntu.
  • Control sobre el cliente que va a fijar la clave: una aplicación móvil, un agente o un script propio.

Dónde tiene sentido la fijación hoy

Antes de configurar nada, conviene saber qué ha cambiado:

  • HPKP (la cabecera Public-Key-Pins) ya no existe en la práctica. Chrome la eliminó en 2019 y Firefox en 2020; ningún navegador actual la procesa. Muchas guías antiguas todavía la recomiendan: no la configures en Nginx ni en Apache, porque no protege nada.
  • Para sitios web públicos, la protección frente a certificados fraudulentos la aportan hoy Certificate Transparency (los navegadores exigen que todo certificado público esté registrado) y los registros CAA en tu DNS, que indican qué CA pueden emitir para tu dominio.
  • La fijación sigue siendo útil cuando controlas el cliente: aplicaciones móviles que hablan con tu API, agentes instalados en servidores de clientes, dispositivos IoT o integraciones máquina a máquina.

En esta guía fijarás la clave pública (SPKI) y no el certificado completo. La clave puede mantenerse entre renovaciones, mientras que el certificado cambia cada vez que se renueva.

Paso 1: Calcular el pin de la clave pública

Un pin es el hash SHA-256 en Base64 de la estructura SubjectPublicKeyInfo de la clave. Puedes obtenerlo del servidor en producción:

openssl s_client -connect your_domain:443 -servername your_domain </dev/null 2>/dev/null \
  | openssl x509 -pubkey -noout \
  | openssl pkey -pubin -outform der \
  | openssl dgst -sha256 -binary \
  | openssl enc -base64
pT/hGdTJcuGYpe3SjUDV74LGF6QDYRzveWH3O5TJVjA=

O directamente de la clave privada en el servidor, que debe dar el mismo resultado:

sudo openssl pkey -in /etc/letsencrypt/live/your_domain/privkey.pem -pubout -outform der \
  | openssl dgst -sha256 -binary | openssl enc -base64

Si los dos valores no coinciden, el servidor está sirviendo otro certificado (revisa el server_name y la ruta del certificado en Nginx) antes de seguir.

Paso 2: Crear una clave de respaldo

Si fijas una sola clave y la pierdes o queda comprometida, tus clientes no podrán conectar hasta que publiques una versión nueva de la aplicación. Por eso siempre se fijan al menos dos claves: la actual y una de respaldo que todavía no se usa.

Genera la clave de respaldo y calcula su pin:

openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out backup.key
chmod 600 backup.key
openssl pkey -in backup.key -pubout -outform der | openssl dgst -sha256 -binary | openssl enc -base64
Qm9ja3VwS2V5UGluRXhhbXBsZVZhbHVlMTIzNDU2Nzg9

Guarda backup.key fuera del servidor, cifrada y en un lugar seguro (un gestor de secretos o un almacenamiento fuera de línea). Una copia de respaldo que vive en el mismo servidor que la clave principal no protege frente a un compromiso de ese servidor.

Paso 3: Mantener la misma clave en cada renovación

Por defecto Certbot genera una clave nueva en cada renovación, lo que cambiaría el pin cada 60 días aproximadamente. Indícale que reutilice la clave actual añadiendo reuse_key a la configuración de renovación del certificado:

sudo nano /etc/letsencrypt/renewal/your_domain.conf

Añade la línea dentro de la sección [renewalparams], sin tocar el resto del archivo:

[renewalparams]
reuse_key = True

Comprueba que la opción ha quedado guardada:

sudo grep reuse_key /etc/letsencrypt/renewal/your_domain.conf
reuse_key = True

Verifica que una renovación simulada funciona con esta configuración:

sudo certbot renew --cert-name your_domain --dry-run

Ahora el pin de tu clave solo cambiará cuando tú decidas rotarla (paso 6).

Paso 4: Probar la fijación con curl

curl implementa la fijación con --pinnedpubkey, que acepta varios pines separados por punto y coma. Es la forma más rápida de probar un pin y también sirve para scripts y agentes propios:

curl -sSI --pinnedpubkey 'sha256//pT/hGdTJcuGYpe3SjUDV74LGF6QDYRzveWH3O5TJVjA=;sha256//Qm9ja3VwS2V5UGluRXhhbXBsZVZhbHVlMTIzNDU2Nzg9' https://your_domain
HTTP/2 200

Comprueba también el caso negativo con un pin incorrecto. La conexión debe fallar con el código de salida 90:

curl -sSI --pinnedpubkey 'sha256//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' https://your_domain; echo "exit=$?"
curl: (90) SSL: public key does not match pinned public key
exit=90

La fijación se suma a la validación normal de la cadena: curl sigue comprobando que el certificado es válido y corresponde al dominio.

Paso 5: Fijar la clave en una aplicación Android

Android permite declarar pines sin escribir código, en el archivo de configuración de seguridad de red de la aplicación. Crea res/xml/network_security_config.xml en el proyecto:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config>
        <domain includeSubdomains="false">your_domain</domain>
        <pin-set expiration="2027-09-01">
            <pin digest="SHA-256">pT/hGdTJcuGYpe3SjUDV74LGF6QDYRzveWH3O5TJVjA=</pin>
            <pin digest="SHA-256">Qm9ja3VwS2V5UGluRXhhbXBsZVZhbHVlMTIzNDU2Nzg9</pin>
        </pin-set>
    </domain-config>
</network-security-config>

Y refiérelo desde el elemento <application> de AndroidManifest.xml:

<application
    android:networkSecurityConfig="@xml/network_security_config"
    ... >

El atributo expiration es una salvaguarda: pasada esa fecha, Android deja de aplicar los pines y vuelve a la validación normal, de forma que una versión antigua de la app que nadie actualiza no queda bloqueada para siempre. Fija una fecha posterior a la próxima rotación planificada.

En iOS no hay un equivalente declarativo tan extendido; lo habitual es implementar la comprobación en el delegado de URLSession o usar una librería como TrustKit con los mismos pines SHA-256.

Paso 6: Rotar la clave sin bloquear clientes

La regla es que un cliente nunca debe recibir una clave que no tenga fijada. Para rotar de forma planificada:

  1. Genera la futura clave como en el paso 2 y publica una versión del cliente que incluya el pin actual, el de respaldo y el de la futura clave.
  2. Espera a que la mayoría de clientes se hayan actualizado. Las métricas de versión de tu app te dirán cuándo.
  3. Emite un certificado con la nueva clave. Certbot no puede adoptar una clave existente en una renovación automática, así que genera una CSR a partir de ella y pide el certificado con --csr:
sudo mkdir -p /etc/ssl/your_domain
openssl req -new -key new.key -subj "/CN=your_domain" \
  -addext "subjectAltName=DNS:your_domain" -out new.csr
sudo certbot certonly --webroot -w /var/www/html --csr new.csr \
  --cert-path /etc/ssl/your_domain/cert.pem \
  --fullchain-path /etc/ssl/your_domain/fullchain.pem \
  --chain-path /etc/ssl/your_domain/chain.pem
  1. Apunta Nginx a /etc/ssl/your_domain/fullchain.pem y a new.key (copiada con permisos 600), recárgalo y comprueba el pin con el comando del paso 1 y con curl --pinnedpubkey usando la lista de pines de la versión anterior del cliente.
  2. En la siguiente versión del cliente, retira el pin antiguo.

Los certificados obtenidos con --csr no se renuevan solos: repite el comando antes de que caduquen (o programa esa misma orden con un temporizador). El mismo procedimiento, a partir de backup.key, es el que usarás en una emergencia si la clave principal queda comprometida.

Solución de problemas

Los clientes fallan tras una renovación. El servidor presenta una clave que no está fijada, normalmente porque la renovación generó una clave nueva. Calcula el pin actual (paso 1) y compáralo con los del cliente. Si todavía tienes la clave anterior, vuelve a emitir el certificado con ella mediante --csr (paso 6) y activa reuse_key para las siguientes renovaciones (paso 3).

curl: (90) desde un servidor pero no desde otro. Un proxy o una inspección TLS intermedia (habitual en redes corporativas o antivirus) está sustituyendo el certificado. Es justo lo que la fijación debe detectar; revisa el certificado recibido con openssl s_client desde esa red.

El pin del servidor y el de la clave local no coinciden. Nginx sirve otro certificado para ese nombre. Ejecuta sudo nginx -T | grep -n ssl_certificate para ver qué rutas usa cada bloque server.

Conclusión

Has calculado pines SHA-256 de clave pública con OpenSSL, preparado una clave de respaldo, configurado Certbot para mantener la clave entre renovaciones y aplicado los pines en curl y en Android, con un procedimiento de rotación que no deja clientes bloqueados. Para sitios web públicos, complementa todo esto con registros CAA en tu DNS y con la monitorización de Certificate Transparency para tu dominio, que avisan de certificados emitidos sin tu conocimiento.