Pourquoi les téléphones sont le cas difficile
Les valeurs par défaut de step-ca sont faites pour des serveurs. Un serveur se renouvelle tout seul chaque matin, et Votre propre autorité de certification : step-ca dans un conteneur montre que ça marche pour les clients ACME et pour tout ce qui peut lancer step ca renew. Cet article couvre aussi le démarrage de l’AC, ses deux mots de passe et la confiance dans la racine, et celui-ci reprend à partir de là. Un téléphone qui détient un certificat client pour l’application Home Assistant ne peut rien renouveler, et quand l’un d’eux disparaît vous devez le bloquer aujourd’hui, pas quand son certificat arrive à expiration. Cinq valeurs par défaut se mettent en travers, et aucune ne fait de bruit. Elles vous laissent simplement avec des certificats qui meurent demain et une liste de révocation qui rejette tout le monde.
| Défaut | Ce qu’il fait à un téléphone | Correctif |
|---|---|---|
| Les certificats durent 24 heures au plus | Le certificat du téléphone meurt demain, et l’application Home Assistant ne peut pas le renouveler | Relever maxTLSCertDuration dans ca.json |
| CRL désactivée | Révoquer un téléphone perdu ne change rien nulle part | Activer le bloc crl |
| La portée de la CRL ne correspond pas aux certificats | nginx rejette tous les téléphones, les bons compris | Poser idpURL et ajouter un modèle avec crlDistributionPoints |
| Pas de CRL pour l’intermédiaire | nginx rejette encore tous les téléphones | Signer une CRL racine vide une fois par an |
| La CRL dure 24 heures | Quand elle expire, nginx rejette de nouveau tous les téléphones | La rafraîchir sur minuterie |
Cloudflare fait pour vous la moitié AC de tout ça sur son offre gratuite, comme le montre la version Cloudflare. Le faire vous-même, c’est faire les cinq. Les tests ci-dessous ont tourné sur une AC nommée Home Assistant CA, et c’est ce nom qui apparaît dans les sorties.
Défaut numéro un : les certificats durent une journée
Demandez un an et l’AC refuse :
The request was forbidden by the certificate authority: requested duration of
8760h1m0s is more than the authorized maximum certificate duration of 24h1m0s.
C’est le bon défaut pour les serveurs et les clients ACME, qui renouvellent toute la journée sans que personne ne s’en aperçoive. Le téléphone est l’exception. L’application Home Assistant n’a aucun moyen de renouveler son propre certificat client, donc un certificat de 24 heures veut dire passer voir chaque téléphone chaque jour.
La durée de validité est un réglage, et il vit dans la configuration de step-ca elle-même. Chaque provisioner de ca.json accepte des claims1 :
"claims": {
"maxTLSCertDuration": "8760h",
"defaultTLSCertDuration": "24h"
}
| Claim | Signification | Valeur choisie |
|---|---|---|
maxTLSCertDuration | Le certificat le plus long que ce provisioner signera | 8760h, un an |
defaultTLSCertDuration | Ce que vous obtenez sans rien demander | Laissé à 24h, pour qu’un an soit toujours un choix délibéré |
minTLSCertDuration | Le plus court qu’il signera | Pas touché |
Redémarrez le conteneur et un an passe. Un an est un jugement, pas une règle. Plus court veut dire moins de temps pendant lequel un certificat volé est utile, et plus de tours de la maison pour les réinstaller.
Défaut numéro deux : la CRL est désactivée
Une liste de révocation de certificats (CRL) est la liste signée des certificats que l’AC a retirés. C’est ainsi qu’un téléphone perdu est bloqué avant l’expiration de son certificat, et step-ca n’en fabrique pas tant qu’on ne le lui dit pas. La documentation marque la fonction comme expérimentale1.
"crl": {
"enabled": true,
"generateOnRevoke": true,
"cacheDuration": "24h"
}
| Champ | Rôle | Défaut |
|---|---|---|
enabled | Fabriquer une CRL tout court | Désactivé |
generateOnRevoke | Reconstruit la liste dès qu’un certificat est révoqué | Désactivé |
cacheDuration | Combien de temps chaque liste reste valide | 24 heures1 |
idpURL | L’URL que la liste dit couvrir | Le nom de l’AC elle-même plus /1.0/crl2 |
Retenez ces 24 heures. Elles reviennent.
Défaut numéro trois : une CRL que rien n’accepte
Avec la CRL activée, révoquez un téléphone et vérifiez les deux téléphones contre elle :
$ 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
Les deux échouent, le bon téléphone compris. Mettez cette liste devant nginx et personne n’entre.
La liste elle-même dit pourquoi. step-ca marque chaque CRL d’un Issuing Distribution Point critique, qui dit « cette liste couvre les certificats qui pointent ici ». Les certificats qu’il a émis ne pointent nulle part :
$ 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 n’appliquera pas une liste dont la portée ne correspond pas au certificat. Il échoue donc en mode fermé. Ce qui est juste, et ne sert à personne.
| La CRL dit couvrir | Le certificat dit que sa CRL est à | Résultat | |
|---|---|---|---|
| Tel que livré | https://localhost/1.0/crl, critique | Nulle part | different CRL scope, tout le monde refusé |
idpURL et un modèle | http://ca.home.example/1.0/crl | http://ca.home.example/1.0/crl | Les bons téléphones passent, les révoqués échouent |
L’URL par défaut n’est même pas une adresse sur laquelle l’AC sert. Le code source de step-ca le dit lui-même : « this is currently using the port 443 by default »2. Deux réglages corrigent ça, et les deux doivent nommer la même URL. idpURL va dans le bloc crl de ca.json, et un modèle sur le provisioner appose le crlDistributionPoints correspondant dans chaque certificat3 :
{
"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" } }
Réémettez les certificats des téléphones, puisque seuls les nouveaux certificats portent le champ, et la même vérification donne la bonne réponse :
CN=phone2
error 23 at 0 depth lookup: certificate revoked
error phone2.crt: verification failed
phone1.crt: OK
La racine a besoin d’une CRL elle aussi
Cette vérification ne regardait que le certificat du téléphone. nginx regarde toute la chaîne. Il pose le drapeau CRL_CHECK_ALL d’OpenSSL4, et sa documentation le dit sans détour : « When using intermediate certificates, their CRLs should be specified in the same file »5. step-ca émet les certificats depuis son intermédiaire et ne liste jamais que les révocations de l’intermédiaire. Rien ne dit si l’intermédiaire lui-même est toujours bon :
$ 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
Le correctif est une CRL signée par la racine qui ne révoque rien. OpenSSL en fabrique une à partir de la clé racine et du fichier de mot de passe de l’AC, et elle peut durer un an parce que la racine ne révoque rien au quotidien :
[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
Les deux listes dans un seul fichier, et toute la chaîne se vérifie :
$ 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
Notez une date dans l’agenda pour dans un an. Quand la CRL racine expire, tous les téléphones sont de nouveau bloqués.
Publier la CRL
La CRL doit vivre là où pointe l’URL de chaque certificat. Pas sur l’AC elle-même.
| Choix | Pourquoi |
|---|---|
| Un serveur web devant, pas le port 9000 | Le port 9000, c’est toute l’API de step-ca, signature comprise. Personne n’a besoin d’autre chose que la liste |
| HTTP simple, pas HTTPS | La RFC 5280 dit qu’un point de distribution « SHOULD include at least one LDAP or HTTP URI »6. Récupérer en HTTPS demande de vérifier un certificat, ce qui demande la liste. HTTP rompt le cercle |
| Pas de souci de falsification | La liste est signée. Changez un octet et la signature échoue |
| Seuls les deux chemins de CRL répondent | Tout le reste est un 404 |
Les deux serveurs web le font en quelques lignes, et les deux transmettent la liste telle quelle depuis step-ca et servent la CRL racine comme un fichier :
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
}
}
| Requête | nginx | Caddy |
|---|---|---|
/1.0/crl | 200, application/pkix-crl, émetteur l’intermédiaire | 200, application/pkix-crl, émetteur l’intermédiaire |
/root.crl | 200, application/pkix-crl | 200, application/pkix-crl |
/roots.pem, /1.0/sign et le reste de l’API | 404 | 404 |
Les deux répondent en IPv6 comme en IPv4, et c’est ce qu’il faut. Caddy écoute sur les deux par défaut. nginx n’écoute que là où on le lui dit, donc listen 80; seul ne fait que de l’IPv4 et a besoin du listen [::]:80; à côté. Testée sur ::1, la liste est revenue en 200 des deux une fois que nginx a eu cette ligne. step-ca lui-même écoute sur les deux d’origine.
Le test a tourné sur les ports 8080 et 8081, donc l’URL des certificats de test portait un port. En service, c’est le port 80 et l’URL est exactement celle du modèle.
La garder fraîche
Chaque CRL que fabrique step-ca porte un nextUpdate à 24 heures. Après ça elle a expiré, et une liste expirée échoue de la même façon qu’une liste absente :
$ 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
C’est le bon téléphone. Deux jours plus tard. Bloqué, parce que rien n’est allé chercher une nouvelle liste.
nginx ne lit ssl_crl qu’au démarrage ou au rechargement. Il faut donc que quelque chose aille chercher la liste, reconstruise le fichier et recharge nginx toutes les quelques heures, aussi longtemps que tout ça tourne, que quelqu’un regarde ou non.
#!/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}
| Exécution | Résultat |
|---|---|
| Normale | Nouvelle liste en place, nginx rechargé, phone1 en 200, phone2 révoqué en 400 |
| AC injoignable | curl: (22) The requested URL returned error: 404, sortie 22, ancienne liste intacte, pas de rechargement |
C’est set -e qui fait le vrai travail. Une récupération ratée ne doit jamais écrire un fichier vide par-dessus un bon, parce qu’une liste cassée bloque tout le monde aussi sûrement qu’une liste périmée. Lancez-le toutes les quelques heures depuis une minuterie systemd ou cron. Ça lui laisse plusieurs essais avant que quoi que ce soit n’expire. Rien de malin, et c’est le morceau qui fait que la porte fonctionne.
Émettre le certificat d’un téléphone
Un certificat par téléphone, au nom du téléphone, pour un an :
step ca certificate phone1 phone1.crt phone1.key \
--provisioner admin --provisioner-password-file prov-pass \
--not-after 8760h --kty EC
Le téléphone veut le certificat, sa clé et l’intermédiaire dans un seul fichier PKCS12, et step le construit lui-même7 :
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
C’est le chiffrement moderne, celui que la documentation de Home Assistant Companion dit d’essayer en premier, avant de se rabattre sur un conteneur legacy8.
Le téléphone a besoin de trois choses de l’AC, pas d’une :
| Fichier | À installer comme | Pourquoi |
|---|---|---|
root_ca.crt | Certificat d’AC | Pour que le téléphone fasse confiance au certificat serveur que step-ca a émis pour le proxy |
intermediate_ca.crt | Certificat d’AC | La chaîne entre la racine et chaque certificat que l’AC signe réellement |
phone1.p12 | Certificat utilisateur VPN et applications | La propre clé du téléphone, ce qui le fait entrer |
L’application Android de Home Assistant fait confiance aux certificats d’AC installés par l’utilisateur en plus de ceux intégrés9, donc un certificat serveur de step-ca fonctionne. C’est même le meilleur verrou pour ce travail. Le nom d’hôte ne compte qu’à l’intérieur de la maison et pour les téléphones, et un appareil sans votre racine ne fait même pas confiance au serveur, et encore moins le franchit.
Quand le téléphone disparaît :
step ca revoke --cert phone2.crt --key phone2.key --reason "phone lost"
Certificate with Serial Number 89334534279272070053632987406394420097 has been revoked.
La nouvelle liste est construite sur-le-champ, et le prochain rafraîchissement la met devant nginx.
Une AC est une promesse que vous tenez chaque jour
En faisant tourner tout ça, ce qui m’a frappé, c’est le peu de travail que représente l’émission des certificats. Une commande. C’est tout. Le travail est entièrement dans les promesses autour : qu’un téléphone révoqué reste dehors, qu’un bon téléphone reste dedans, que la liste ne soit jamais périmée. step-ca tient la première et vous laisse le reste, et ses valeurs par défaut supposent que vous êtes une flotte de serveurs qui se renouvellent chaque matin.
Ce n’est pas un défaut, c’est le public pour lequel il a été construit. Mais c’est l’écart entre ce que fait un outil et ce qu’on prétend qu’il fait. « Faites tourner votre propre AC », ça sonne comme un conteneur et un après-midi. C’est un conteneur, un après-midi, un modèle, une CRL racine avec une date dans l’agenda, et une minuterie dont vous oublierez l’existence jusqu’au jour où elle s’arrête.
Faites-le quand même. Une AC que vous faites tourner, personne d’autre ne peut l’éteindre, en changer le prix ou la lire. Sachez seulement que posséder les clés, c’est posséder les corvées, et que ce sont les corvées qui gardent la porte fermée.
Smallstep — step-ca configuration —
maxTLSCertDuration,defaultTLSCertDuration,minTLSCertDuration; le bloccrlest « (experimental) »,cacheDuration« Defaults to 24h ». ↩︎ ↩︎ ↩︎smallstep/certificates v0.30.2 — authority/tls.go — l’Issuing Distribution Point de la CRL est
idpURLs’il est posé, sinon le nom de l’AC elle-même plus/1.0/crl, marqué critique. ↩︎ ↩︎Smallstep — Certificate templates —
crlDistributionPoints« defines URLs with the certificate revocation list ». ↩︎nginx — src/event/ngx_event_openssl.c —
ssl_crlposeX509_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 — section 4.2.1.13 : le DistributionPointName « SHOULD include at least one LDAP or HTTP URI ». ↩︎
Smallstep — step certificate p12 — empaquette un certificat, sa clé et des certificats d’AC dans un fichier PKCS12. ↩︎
Home Assistant Companion — Networking, TLS Client Authentication — essayer d’abord le PKCS12 par défaut actuel, le conteneur OpenSSL
-legacyseulement si ça échoue. ↩︎home-assistant/android — network_security_config.xml — les ancres de confiance sont
systemplususer: « Additionally trust user added CAs ». ↩︎