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éfautCe qu’il fait à un téléphoneCorrectif
Les certificats durent 24 heures au plusLe certificat du téléphone meurt demain, et l’application Home Assistant ne peut pas le renouvelerRelever maxTLSCertDuration dans ca.json
CRL désactivéeRévoquer un téléphone perdu ne change rien nulle partActiver le bloc crl
La portée de la CRL ne correspond pas aux certificatsnginx rejette tous les téléphones, les bons comprisPoser idpURL et ajouter un modèle avec crlDistributionPoints
Pas de CRL pour l’intermédiairenginx rejette encore tous les téléphonesSigner une CRL racine vide une fois par an
La CRL dure 24 heuresQuand elle expire, nginx rejette de nouveau tous les téléphonesLa 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"
}
ClaimSignificationValeur choisie
maxTLSCertDurationLe certificat le plus long que ce provisioner signera8760h, un an
defaultTLSCertDurationCe que vous obtenez sans rien demanderLaissé à 24h, pour qu’un an soit toujours un choix délibéré
minTLSCertDurationLe plus court qu’il signeraPas 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"
}
ChampRôleDéfaut
enabledFabriquer une CRL tout courtDésactivé
generateOnRevokeReconstruit la liste dès qu’un certificat est révoquéDésactivé
cacheDurationCombien de temps chaque liste reste valide24 heures1
idpURLL’URL que la liste dit couvrirLe 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 couvrirLe certificat dit que sa CRL est àRésultat
Tel que livréhttps://localhost/1.0/crl, critiqueNulle partdifferent CRL scope, tout le monde refusé
idpURL et un modèlehttp://ca.home.example/1.0/crlhttp://ca.home.example/1.0/crlLes 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
Quelle CRL couvre quel certificatAC racineapprouvée telle quelleIntermédiairesigne chaque certificatphone1bonphone2révoquéServeurha.example.comsignenginx vérifie chaque maillonun maillon sans CRL est refuséCRL racine365 jours, par OpenSSLCRL step-ca24 heures, liste phone2couvrecouvre
Chaque maillon sous la racine a besoin d’une CRL émise par le certificat au-dessus de lui. step-ca couvre la rangée du bas. Rien ne couvre l’intermédiaire tant que vous ne signez pas vous-même une CRL racine.

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.

ChoixPourquoi
Un serveur web devant, pas le port 9000Le 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 HTTPSLa 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 falsificationLa liste est signée. Changez un octet et la signature échoue
Seuls les deux chemins de CRL répondentTout 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êtenginxCaddy
/1.0/crl200, application/pkix-crl, émetteur l’intermédiaire200, application/pkix-crl, émetteur l’intermédiaire
/root.crl200, application/pkix-crl200, application/pkix-crl
/roots.pem, /1.0/sign et le reste de l’API404404

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.

Publier la CRL et la garder fraîchestep-ca:9000, toute l'APIgardée à l'intérieurnginx ou Caddyport 80, v4 et v6/1.0/crl, /root.crlle reste en 404La liste expireau bout de 24 heures,tout téléphone refuséMinuterietoutes les quelques heuresProxy mTLSssl_crl, rechargéCRLrécupèrerecharges'arrête
L’API de step-ca reste à l’intérieur. Le serveur web ne distribue que les deux listes, et une minuterie va chercher la fraîche et recharge le proxy. Arrêtez la minuterie et la liste expire un jour plus tard, bloquant tous les téléphones.

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écutionRésultat
NormaleNouvelle liste en place, nginx rechargé, phone1 en 200, phone2 révoqué en 400
AC injoignablecurl: (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 commePourquoi
root_ca.crtCertificat d’ACPour que le téléphone fasse confiance au certificat serveur que step-ca a émis pour le proxy
intermediate_ca.crtCertificat d’ACLa chaîne entre la racine et chaque certificat que l’AC signe réellement
phone1.p12Certificat utilisateur VPN et applicationsLa 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.


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

  2. smallstep/certificates v0.30.2 — authority/tls.go — l’Issuing Distribution Point de la CRL est idpURL s’il est posé, sinon le nom de l’AC elle-même plus /1.0/crl, marqué critique. ↩︎ ↩︎

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

  4. nginx — src/event/ngx_event_openssl.c — ssl_crl pose 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 — section 4.2.1.13 : le DistributionPointName « SHOULD include at least one LDAP or HTTP URI ». ↩︎

  7. Smallstep — step certificate p12 — empaquette un certificat, sa clé et des certificats d’AC dans un fichier PKCS12. ↩︎

  8. Home Assistant Companion — Networking, TLS Client Authentication — essayer d’abord le PKCS12 par défaut actuel, le conteneur OpenSSL -legacy seulement si ça échoue. ↩︎

  9. home-assistant/android — network_security_config.xml — les ancres de confiance sont system plus user : « Additionally trust user added CAs ». ↩︎