Por qué los móviles son el caso difícil

Los valores por defecto de step-ca están pensados para servidores. Un servidor se renueva solo cada mañana, y Tu propia autoridad de certificación: step-ca en un contenedor muestra eso funcionando para los clientes ACME y cualquier otra cosa que pueda ejecutar step ca renew. Esa entrada también cubre arrancar la CA, sus dos contraseñas y confiar en la raíz, y esta sigue desde ahí. Un móvil con un certificado de cliente para la app de Home Assistant no puede renovar nada, y cuando uno desaparece tienes que dejarlo fuera hoy, no cuando caduque su certificado. Cinco valores por defecto se interponen, y ninguno da la voz de alarma. Simplemente te dejan con certificados que mueren mañana y una lista de revocación que rechaza a todo el mundo.

Valor por defectoQué le hace a un móvilArreglo
Los certificados duran 24 horas como muchoEl certificado del móvil muere mañana, y la app de Home Assistant no puede renovarloSubir maxTLSCertDuration en ca.json
CRL desactivadaRevocar un móvil perdido no cambia nada en ningún sitioActivar el bloque crl
El alcance de la CRL no coincide con los certificadosnginx rechaza todos los móviles, también los buenosPoner idpURL y añadir una plantilla con crlDistributionPoints
No hay CRL para el intermedionginx sigue rechazando todos los móvilesFirmar una CRL de la raíz vacía una vez al año
La CRL dura 24 horasCuando caduca, nginx vuelve a rechazar todos los móvilesRefrescarla con un temporizador

Cloudflare te hace la mitad de CA de esto en su plan gratuito, como muestra la versión de Cloudflare. Hacerlo tú mismo significa hacer las cinco. Las pruebas de abajo se hicieron con una CA llamada Home Assistant CA, que es el nombre que sale en la salida.

Valor por defecto uno: los certificados duran un día

Pide un año y la CA dice que no:

The request was forbidden by the certificate authority: requested duration of
8760h1m0s is more than the authorized maximum certificate duration of 24h1m0s.

Es el valor por defecto correcto para servidores y clientes ACME, que renuevan todo el día sin que nadie se entere. Un móvil es la excepción. La app de Home Assistant no tiene forma de renovar su propio certificado de cliente, así que un certificado de 24 horas significa ir a cada móvil cada día.

El periodo de validez es un ajuste, y vive en la propia configuración de step-ca. Cada aprovisionador de ca.json admite claims1:

"claims": {
  "maxTLSCertDuration": "8760h",
  "defaultTLSCertDuration": "24h"
}
ClaimSignificadoValor
maxTLSCertDurationEl certificado más largo que firmará este aprovisionador8760h, un año
defaultTLSCertDurationLo que recibes sin pedirloSe deja en 24h, para que un año sea siempre una elección deliberada
minTLSCertDurationEl más corto que firmaráSin tocar

Reinicia el contenedor y un año pasa. Un año es una cuestión de criterio, no una regla. Más corto significa menos tiempo para que un certificado robado sirva de algo, y más vueltas por la casa reinstalándolos.

Valor por defecto dos: la CRL está apagada

Una lista de revocación de certificados (CRL) es la lista firmada de la CA con los certificados que ha retirado. Es como se deja fuera un móvil perdido antes de que caduque su certificado, y step-ca no hace una si no se le dice. La documentación marca la función como experimental1.

"crl": {
  "enabled": true,
  "generateOnRevoke": true,
  "cacheDuration": "24h"
}
CampoHacePor defecto
enabledQue haya CRLApagado
generateOnRevokeReconstruye la lista en el momento en que se revoca un certificadoApagado
cacheDurationCuánto tiempo es válida cada lista24 horas1
idpURLLa URL que la lista dice cubrirEl propio nombre de la CA más /1.0/crl2

Acuérdate de esas 24 horas. Vuelven.

Valor por defecto tres: una CRL que nada acepta

Con la CRL activada, revoca un móvil y comprueba los dos móviles contra ella:

$ openssl verify -CAfile client-ca.pem -CRLfile crl.pem -crl_check phone1.crt phone2.crt
CN=phone1
error 44 at 0 depth lookup: different CRL scope
error phone1.crt: verification failed
CN=phone2
error 44 at 0 depth lookup: different CRL scope
error phone2.crt: verification failed

Los dos fallan, el bueno incluido. Pon esa lista delante de nginx y no entra nadie.

La propia lista dice por qué. step-ca marca cada CRL con un Issuing Distribution Point crítico, que dice «esta lista cubre los certificados que apuntan aquí». Los certificados que emitió no apuntan a ningún sitio:

$ openssl crl -in crl.pem -noout -text
        X509v3 Issuing Distribution Point: critical
            Full Name:
              URI:https://localhost/1.0/crl
$ openssl x509 -in phone1.crt -noout -ext crlDistributionPoints
No extensions in certificate

OpenSSL no aplica una lista cuyo alcance no coincide con el certificado. Así que falla cerrado. Lo cual es correcto, y no le sirve a nadie.

La CRL dice que cubreEl certificado dice que su CRL está enResultado
De fábricahttps://localhost/1.0/crl, críticoEn ningún sitiodifferent CRL scope, todos rechazados
idpURL y una plantillahttp://ca.home.example/1.0/crlhttp://ca.home.example/1.0/crlLos móviles buenos pasan, los revocados fallan

La URL por defecto ni siquiera es una en la que sirva la CA. El código de step-ca lo reconoce: «this is currently using the port 443 by default»2. Dos ajustes lo arreglan, y los dos tienen que nombrar la misma URL. idpURL va en el bloque crl de ca.json, y una plantilla en el aprovisionador graba el crlDistributionPoints correspondiente en cada certificado3:

{
  "subject": {{ toJson .Subject }},
  "sans": {{ toJson .SANs }},
  "keyUsage": ["digitalSignature"],
  "extKeyUsage": ["serverAuth", "clientAuth"],
  "crlDistributionPoints": ["http://ca.home.example/1.0/crl"]
}
"options": { "x509": { "templateFile": "templates/leaf-crl.tpl" } }

Vuelve a emitir los certificados de los móviles, ya que solo los certificados nuevos llevan el campo, y la misma comprobación da la respuesta correcta:

CN=phone2
error 23 at 0 depth lookup: certificate revoked
error phone2.crt: verification failed
phone1.crt: OK

La raíz también necesita una CRL

Esa comprobación solo miraba el certificado del móvil. nginx mira toda la cadena. Activa la opción CRL_CHECK_ALL de OpenSSL4, y su documentación lo dice claramente: «When using intermediate certificates, their CRLs should be specified in the same file»5. step-ca emite certificados desde su intermedio y solo lista las revocaciones del intermedio. Nada dice si el propio intermedio sigue siendo válido:

$ openssl verify -CAfile client-ca.pem -CRLfile crl.pem -crl_check_all phone1.crt
O=Home Assistant CA, CN=Home Assistant CA Intermediate CA
error 3 at 1 depth lookup: unable to get certificate CRL
Qué CRL cubre qué certificadoCA raízde confianza tal cualIntermediofirma cada certificadophone1buenophone2revocadoServidorha.example.comfirmanginx comprueba cada eslabónun eslabón sin CRL se rechazaCRL de la raíz365 días, de OpenSSLCRL de step-ca24 horas, lista phone2cubrecubre
Cada eslabón por debajo de la raíz necesita una CRL del certificado de encima. step-ca cubre la fila de abajo. Nada cubre el intermedio hasta que firmas tú mismo una CRL de la raíz.

El arreglo es una CRL firmada por la raíz que no revoca nada. OpenSSL hace una con la clave de la raíz y el propio archivo de contraseña de la CA, y puede durar un año porque la raíz no revoca nada en el día a día:

[ca]
default_ca = root
[root]
database = index.txt
crlnumber = crlnumber
default_md = sha256
default_crl_days = 365
private_key = root_ca_key
certificate = root_ca.crt
crl_extensions = crl_ext
[crl_ext]
authorityKeyIdentifier = keyid:always
touch index.txt && echo 1000 > crlnumber
openssl ca -config root-crl.cnf -gencrl -passin file:password -out root.crl.pem

Las dos listas en un archivo, y toda la cadena sale bien:

$ openssl verify -CAfile client-ca.pem -CRLfile crl-chain.pem -crl_check_all phone1.crt phone2.crt
CN=phone2
error 23 at 0 depth lookup: certificate revoked
error phone2.crt: verification failed
phone1.crt: OK

Apunta una fecha en la agenda para dentro de un año. Cuando caduque la CRL de la raíz, todos los móviles vuelven a quedarse fuera.

Publicar la CRL

La CRL tiene que estar donde apunta la URL de cada certificado. No en la propia CA.

ElecciónPor qué
Un servidor web delante, no el puerto 9000El puerto 9000 es toda la API de step-ca, firma incluida. Nadie necesita nada de eso salvo la lista
HTTP normal, no HTTPSEl RFC 5280 dice que un punto de distribución «SHOULD include at least one LDAP or HTTP URI»6. Descargar por HTTPS necesita comprobar un certificado, lo que necesita la lista. HTTP rompe el círculo
Sin preocuparse por la manipulaciónLa lista va firmada. Cambia un byte y la firma falla
Solo responden las dos rutas de CRLTodo lo demás es un 404

Los dos servidores web lo hacen en unas pocas líneas, y los dos pasan la lista directamente desde step-ca y sirven la CRL de la raíz como archivo:

server {
    listen 80;
    listen [::]:80;
    server_name ca.home.example;

    location = /1.0/crl {
        proxy_pass https://127.0.0.1:9000;
        proxy_ssl_trusted_certificate /etc/step/root_ca.crt;
        proxy_ssl_verify on;
        proxy_ssl_name localhost;
    }

    location = /root.crl {
        alias /etc/step/root.crl;
        default_type application/pkix-crl;
    }

    location / {
        return 404;
    }
}
http://ca.home.example {
	handle /1.0/crl {
		reverse_proxy https://127.0.0.1:9000 {
			transport http {
				tls_trust_pool file /etc/step/root_ca.crt
				tls_server_name localhost
			}
		}
	}
	handle /root.crl {
		header Content-Type application/pkix-crl
		root * /etc/step
		file_server
	}
	handle {
		respond 404
	}
}
PeticiónnginxCaddy
/1.0/crl200, application/pkix-crl, emisor el intermedio200, application/pkix-crl, emisor el intermedio
/root.crl200, application/pkix-crl200, application/pkix-crl
/roots.pem, /1.0/sign y el resto de la API404404

Los dos responden por IPv6 además de por IPv4, y deben hacerlo. Caddy escucha en los dos por defecto. nginx solo escucha donde se le dice, así que listen 80; a secas es solo IPv4 y necesita listen [::]:80; al lado. Probado sobre ::1, la lista volvió con 200 de los dos una vez que nginx tuvo esa línea. El propio step-ca escucha en los dos de fábrica.

La prueba se hizo en los puertos 8080 y 8081, así que la URL de los certificados de prueba llevaba puerto. En uso es el puerto 80 y la URL es exactamente la que dice la plantilla.

Mantenerla fresca

Cada CRL que hace step-ca lleva un nextUpdate a 24 horas vista. Después ha caducado, y una lista caducada falla igual que una que falta:

$ openssl verify -CAfile client-ca.pem -CRLfile crl-chain.pem -crl_check_all \
    -attime $(( $(date +%s) + 172800 )) phone1.crt
error 12 at 0 depth lookup: CRL has expired

Ese es el móvil bueno. Dos días después. Fuera, porque nada descargó una lista nueva.

Publicar la CRL y mantenerla frescastep-ca:9000, toda la APIse queda dentronginx o Caddypuerto 80, v4 y v6/1.0/crl, /root.crlel resto, 404La lista caducaa las 24 horas,todos los móviles fueraTemporizadorcada pocas horasproxy mTLSssl_crl, recargadoCRLdescargarecargase para
La API de step-ca se queda dentro. El servidor web solo reparte las dos listas, y un temporizador descarga la fresca y recarga el proxy. Para el temporizador y la lista caduca un día después, dejando fuera a todos los móviles.

nginx solo lee ssl_crl cuando arranca o se recarga. Así que algo tiene que descargar la lista, reconstruir el archivo y recargar nginx cada pocas horas, mientras esto funcione, lo esté mirando alguien o no.

#!/bin/sh
# Fetch step-ca's CRL, add the root CRL, and reload nginx only if both parse.
set -eu
dir=${CRL_DIR:-/etc/nginx/pki}
tmp=$(mktemp)
trap 'rm -f "$tmp" "$tmp.pem"' EXIT
curl -fsS --max-time 20 "${CRL_URL:-http://ca.home.example/1.0/crl}" -o "$tmp"
openssl crl -inform DER -in "$tmp" -out "$tmp.pem"
openssl crl -in "$dir/root.crl.pem" -noout
cat "$tmp.pem" "$dir/root.crl.pem" > "$dir/crl-chain.pem.new"
mv "$dir/crl-chain.pem.new" "$dir/crl-chain.pem"
${RELOAD:-nginx -s reload}
EjecuciónResultado
NormalLista nueva en su sitio, nginx recargado, phone1 200, phone2 revocado 400
CA inalcanzablecurl: (22) The requested URL returned error: 404, salida 22, la lista vieja intacta, sin recarga

set -e hace el trabajo de verdad. Una descarga fallida nunca debe escribir un archivo vacío encima de uno bueno, porque una lista rota deja a todos fuera igual de seguro que una vieja. Ejecútalo cada pocas horas desde un temporizador de systemd o cron. Eso le da varios intentos antes de que caduque nada. Nada ingenioso, y es la parte que mantiene la puerta funcionando.

Emitir el certificado de un móvil

Un certificado por móvil, con el nombre del móvil, para un año:

step ca certificate phone1 phone1.crt phone1.key \
  --provisioner admin --provisioner-password-file prov-pass \
  --not-after 8760h --kty EC

El móvil quiere el certificado, su clave y el intermedio en un archivo PKCS12, y step lo construye él solo7:

step certificate p12 phone1.p12 phone1.crt phone1.key \
  --ca certs/intermediate_ca.crt
MAC: sha256, Iteration 2048
PKCS7 Encrypted data: PBES2, PBKDF2, AES-256-CBC, Iteration 2048, PRF hmacWithSHA256

Ese es el cifrado moderno, que es lo que la documentación de Home Assistant Companion dice que pruebes primero, antes de recurrir a un contenedor heredado8.

El móvil necesita tres cosas de la CA, no una:

ArchivoInstalar comoPor qué
root_ca.crtCertificado de CAPara que el móvil confíe en el certificado de servidor que step-ca emitió para el proxy
intermediate_ca.crtCertificado de CALa cadena entre la raíz y cada certificado que la CA firma de verdad
phone1.p12Certificado de usuario de VPN y aplicacionesLa propia clave del móvil, lo que le deja entrar

La app de Home Assistant para Android confía en los certificados de CA que instala el usuario además de en los de serie9, así que un certificado de servidor de step-ca funciona. Además es el mejor candado para este trabajo. El nombre de host solo importa dentro de casa y para los móviles, y un dispositivo sin tu raíz ni siquiera confía en el servidor, ya no digamos pasar de él.

Cuando el móvil desaparece:

step ca revoke --cert phone2.crt --key phone2.key --reason "phone lost"
Certificate with Serial Number 89334534279272070053632987406394420097 has been revoked.

La lista nueva se construye en el acto, y el siguiente refresco la pone delante de nginx.

Una CA es una promesa que cumples cada día

Llevando esto, lo que me llamó la atención es lo poco del trabajo que es emitir certificados. Un comando. Eso es todo. El trabajo está en las promesas de alrededor: que un móvil revocado se quede fuera, que uno bueno se quede dentro, que la lista nunca esté vieja. step-ca cumple la primera y te deja el resto a ti, y sus valores por defecto dan por hecho que eres una flota de servidores renovándose cada mañana.

Eso no es un defecto, es para quien se construyó. Pero es la distancia entre lo que hace una herramienta y lo que se vende que hace. «Monta tu propia CA» suena a un contenedor y una tarde. Es un contenedor, una tarde, una plantilla, una CRL de la raíz con una fecha en la agenda, y un temporizador que olvidarás que existe hasta el día que se pare.

Hazlo de todos modos. Una CA que llevas tú es una que nadie más puede apagar, subir de precio ni leer. Solo ten claro que tener las claves significa tener las tareas, y las tareas son lo que mantiene la puerta cerrada.


  1. Smallstep — step-ca configuration — maxTLSCertDuration, defaultTLSCertDuration, minTLSCertDuration; el bloque crl es «(experimental)», cacheDuration «Defaults to 24h». ↩︎ ↩︎ ↩︎

  2. smallstep/certificates v0.30.2 — authority/tls.go — el Issuing Distribution Point de la CRL es idpURL si está puesto, si no el propio nombre de la CA más /1.0/crl, marcado como crítico. ↩︎ ↩︎

  3. Smallstep — Certificate templates — crlDistributionPoints «defines URLs with the certificate revocation list». ↩︎

  4. nginx — src/event/ngx_event_openssl.c — ssl_crl pone X509_V_FLAG_CRL_CHECK|X509_V_FLAG_CRL_CHECK_ALL. ↩︎

  5. nginx — ngx_http_ssl_module, ssl_crl — «When using intermediate certificates, their CRLs should be specified in the same file.» ↩︎

  6. RFC 5280 — Internet X.509 PKI Certificate and CRL Profile — sección 4.2.1.13: el DistributionPointName «SHOULD include at least one LDAP or HTTP URI». ↩︎

  7. Smallstep — step certificate p12 — empaqueta un certificado, su clave y los certificados de CA en un archivo PKCS12. ↩︎

  8. Home Assistant Companion — Networking, TLS Client Authentication — prueba primero el PKCS12 por defecto actual, y el contenedor -legacy de OpenSSL solo si eso falla. ↩︎

  9. home-assistant/android — network_security_config.xml — los anclajes de confianza son system más user: «Additionally trust user added CAs». ↩︎