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 defecto | Qué le hace a un móvil | Arreglo |
|---|---|---|
| Los certificados duran 24 horas como mucho | El certificado del móvil muere mañana, y la app de Home Assistant no puede renovarlo | Subir maxTLSCertDuration en ca.json |
| CRL desactivada | Revocar un móvil perdido no cambia nada en ningún sitio | Activar el bloque crl |
| El alcance de la CRL no coincide con los certificados | nginx rechaza todos los móviles, también los buenos | Poner idpURL y añadir una plantilla con crlDistributionPoints |
| No hay CRL para el intermedio | nginx sigue rechazando todos los móviles | Firmar una CRL de la raíz vacía una vez al año |
| La CRL dura 24 horas | Cuando caduca, nginx vuelve a rechazar todos los móviles | Refrescarla 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"
}
| Claim | Significado | Valor |
|---|---|---|
maxTLSCertDuration | El certificado más largo que firmará este aprovisionador | 8760h, un año |
defaultTLSCertDuration | Lo que recibes sin pedirlo | Se deja en 24h, para que un año sea siempre una elección deliberada |
minTLSCertDuration | El 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"
}
| Campo | Hace | Por defecto |
|---|---|---|
enabled | Que haya CRL | Apagado |
generateOnRevoke | Reconstruye la lista en el momento en que se revoca un certificado | Apagado |
cacheDuration | Cuánto tiempo es válida cada lista | 24 horas1 |
idpURL | La URL que la lista dice cubrir | El 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 cubre | El certificado dice que su CRL está en | Resultado | |
|---|---|---|---|
| De fábrica | https://localhost/1.0/crl, crítico | En ningún sitio | different CRL scope, todos rechazados |
idpURL y una plantilla | http://ca.home.example/1.0/crl | http://ca.home.example/1.0/crl | Los 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
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ón | Por qué |
|---|---|
| Un servidor web delante, no el puerto 9000 | El puerto 9000 es toda la API de step-ca, firma incluida. Nadie necesita nada de eso salvo la lista |
| HTTP normal, no HTTPS | El 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ón | La lista va firmada. Cambia un byte y la firma falla |
| Solo responden las dos rutas de CRL | Todo 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ón | nginx | Caddy |
|---|---|---|
/1.0/crl | 200, application/pkix-crl, emisor el intermedio | 200, application/pkix-crl, emisor el intermedio |
/root.crl | 200, application/pkix-crl | 200, application/pkix-crl |
/roots.pem, /1.0/sign y el resto de la API | 404 | 404 |
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.
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ón | Resultado |
|---|---|
| Normal | Lista nueva en su sitio, nginx recargado, phone1 200, phone2 revocado 400 |
| CA inalcanzable | curl: (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:
| Archivo | Instalar como | Por qué |
|---|---|---|
root_ca.crt | Certificado de CA | Para que el móvil confíe en el certificado de servidor que step-ca emitió para el proxy |
intermediate_ca.crt | Certificado de CA | La cadena entre la raíz y cada certificado que la CA firma de verdad |
phone1.p12 | Certificado de usuario de VPN y aplicaciones | La 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.
Smallstep — step-ca configuration —
maxTLSCertDuration,defaultTLSCertDuration,minTLSCertDuration; el bloquecrles «(experimental)»,cacheDuration«Defaults to 24h». ↩︎ ↩︎ ↩︎smallstep/certificates v0.30.2 — authority/tls.go — el Issuing Distribution Point de la CRL es
idpURLsi está puesto, si no el propio nombre de la CA más/1.0/crl, marcado como crítico. ↩︎ ↩︎Smallstep — Certificate templates —
crlDistributionPoints«defines URLs with the certificate revocation list». ↩︎nginx — src/event/ngx_event_openssl.c —
ssl_crlponeX509_V_FLAG_CRL_CHECK|X509_V_FLAG_CRL_CHECK_ALL. ↩︎nginx — ngx_http_ssl_module, ssl_crl — «When using intermediate certificates, their CRLs should be specified in the same file.» ↩︎
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». ↩︎
Smallstep — step certificate p12 — empaqueta un certificado, su clave y los certificados de CA en un archivo PKCS12. ↩︎
Home Assistant Companion — Networking, TLS Client Authentication — prueba primero el PKCS12 por defecto actual, y el contenedor
-legacyde OpenSSL solo si eso falla. ↩︎home-assistant/android — network_security_config.xml — los anclajes de confianza son
systemmásuser: «Additionally trust user added CAs». ↩︎