Warum Telefone der schwierige Fall sind
Die Voreinstellungen von step-ca sind für Server gebaut. Ein Server erneuert sich jeden Morgen selbst, und Deine eigene Zertifizierungsstelle: step-ca im Container zeigt, wie das für ACME-Clients und alles andere funktioniert, was step ca renew ausführen kann. Dieser Beitrag behandelt auch den Start der CA, ihre zwei Passwörter und das Vertrauen in den Root, und hier geht es von dort aus weiter. Ein Telefon mit einem Client-Zertifikat für die Home-Assistant-App kann nichts erneuern, und wenn eins verschwindet, musst du es heute aussperren, nicht erst, wenn sein Zertifikat abläuft. Fünf Voreinstellungen stehen dem im Weg, und keine davon meldet sich laut. Sie lassen dich einfach mit Zertifikaten zurück, die morgen sterben, und einer Sperrliste, die jeden abweist.
| Voreinstellung | Was sie mit einem Telefon macht | Lösung |
|---|---|---|
| Zertifikate gelten höchstens 24 Stunden | Das Zertifikat des Telefons stirbt morgen, und die Home-Assistant-App kann es nicht erneuern | maxTLSCertDuration in ca.json anheben |
| CRL ausgeschaltet | Ein verlorenes Telefon zu widerrufen ändert nirgendwo etwas | Den crl-Block einschalten |
| Geltungsbereich der CRL passt nicht zu den Zertifikaten | nginx weist jedes Telefon ab, auch die guten | idpURL setzen und eine Vorlage mit crlDistributionPoints hinzufügen |
| Keine CRL für die Zwischen-CA | nginx weist immer noch jedes Telefon ab | Einmal im Jahr eine leere Root-CRL signieren |
| CRL gilt 24 Stunden | Läuft sie ab, weist nginx wieder jedes Telefon ab | Per Timer auffrischen |
Cloudflare erledigt die CA-Hälfte davon in seinem kostenlosen Tarif für dich, wie die Cloudflare-Variante zeigt. Selbst machen heißt, alle fünf anzugehen. Die Tests unten liefen auf einer CA namens Home Assistant CA, und das ist der Name in der Ausgabe.
Voreinstellung eins: Zertifikate gelten einen Tag
Bitte um ein Jahr, und die CA sagt nein:
The request was forbidden by the certificate authority: requested duration of
8760h1m0s is more than the authorized maximum certificate duration of 24h1m0s.
Für Server und ACME-Clients ist das die richtige Voreinstellung, die erneuern den ganzen Tag, ohne dass es jemand merkt. Ein Telefon ist die Ausnahme. Die Home-Assistant-App hat keine Möglichkeit, ihr eigenes Client-Zertifikat zu erneuern, also heißt ein 24-Stunden-Zertifikat, jeden Tag zu jedem Telefon zu laufen.
Die Gültigkeitsdauer ist eine Einstellung, und sie steht in der eigenen Konfiguration von step-ca. Jeder Provisioner in ca.json nimmt claims1:
"claims": {
"maxTLSCertDuration": "8760h",
"defaultTLSCertDuration": "24h"
}
| Claim | Bedeutung | Gesetzt auf |
|---|---|---|
maxTLSCertDuration | Das längste Zertifikat, das dieser Provisioner signiert | 8760h, ein Jahr |
defaultTLSCertDuration | Was du bekommst, ohne zu fragen | Bei 24h belassen, damit ein Jahr immer eine bewusste Entscheidung ist |
minTLSCertDuration | Das kürzeste, das er signiert | Unverändert |
Starte den Container neu, und ein Jahr geht durch. Ein Jahr ist eine Abwägung, keine Regel. Kürzer heißt weniger Zeit, in der ein gestohlenes Zertifikat nützlich ist, und mehr Runden durchs Haus, um sie neu zu installieren.
Voreinstellung zwei: Die CRL ist aus
Eine Zertifikatssperrliste (CRL) ist die signierte Liste der CA mit den Zertifikaten, die sie zurückgezogen hat. Mit ihr wird ein verlorenes Telefon ausgesperrt, bevor sein Zertifikat abläuft, und step-ca erstellt keine, solange man es ihm nicht sagt. Die Doku kennzeichnet die Funktion als experimentell1.
"crl": {
"enabled": true,
"generateOnRevoke": true,
"cacheDuration": "24h"
}
| Feld | Bewirkt | Voreinstellung |
|---|---|---|
enabled | Dass überhaupt eine CRL entsteht | Aus |
generateOnRevoke | Baut die Liste in dem Moment neu, in dem ein Zertifikat widerrufen wird | Aus |
cacheDuration | Wie lange jede Liste gültig bleibt | 24 Stunden1 |
idpURL | Die URL, für die die Liste nach eigener Aussage gilt | Der eigene Name der CA plus /1.0/crl2 |
Merk dir diese 24 Stunden. Sie kommen wieder.
Voreinstellung drei: Eine CRL, die nichts akzeptiert
Mit eingeschalteter CRL ein Telefon widerrufen und beide Telefone dagegen prüfen:
$ 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
Beide scheitern, auch das gute Telefon. Stell diese Liste vor nginx, und keiner kommt rein.
Die Liste selbst sagt, warum. step-ca versieht jede CRL mit einem kritischen Issuing Distribution Point, der besagt: „diese Liste gilt für Zertifikate, die hierher zeigen“. Die ausgestellten Zertifikate zeigen nirgendwohin:
$ 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 wendet keine Liste an, deren Geltungsbereich nicht zum Zertifikat passt. Also schlägt es geschlossen fehl. Was richtig ist, und niemandem hilft.
| Die CRL gilt laut eigener Angabe für | Das Zertifikat sagt, seine CRL liegt unter | Ergebnis | |
|---|---|---|---|
| Ab Werk | https://localhost/1.0/crl, kritisch | Nirgends | different CRL scope, alle abgewiesen |
idpURL und eine Vorlage | http://ca.home.example/1.0/crl | http://ca.home.example/1.0/crl | Gute Telefone kommen durch, widerrufene scheitern |
Die voreingestellte URL ist nicht einmal eine, unter der die CA antwortet. Der Quellcode von step-ca sagt es selbst: „this is currently using the port 443 by default“2. Zwei Einstellungen beheben das, und beide müssen dieselbe URL nennen. idpURL kommt in den crl-Block von ca.json, und eine Vorlage am Provisioner stempelt die passenden crlDistributionPoints in jedes Zertifikat3:
{
"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" } }
Stell die Zertifikate der Telefone neu aus, denn nur neue Zertifikate tragen das Feld, und dieselbe Prüfung liefert die richtige Antwort:
CN=phone2
error 23 at 0 depth lookup: certificate revoked
error phone2.crt: verification failed
phone1.crt: OK
Auch der Root braucht eine CRL
Diese Prüfung hat sich nur das Zertifikat des Telefons angesehen. nginx sieht sich die ganze Kette an. Es setzt OpenSSLs Flag CRL_CHECK_ALL4, und seine Doku sagt es klar: „When using intermediate certificates, their CRLs should be specified in the same file“5. step-ca stellt Zertifikate von seiner Zwischen-CA aus und führt immer nur die Widerrufe der Zwischen-CA auf. Nichts sagt, ob die Zwischen-CA selbst noch gut ist:
$ 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
Die Lösung ist eine vom Root signierte CRL, die nichts widerruft. OpenSSL erzeugt sie aus dem Root-Schlüssel und der Passwortdatei der CA, und sie kann ein Jahr gelten, weil der Root im Alltag nichts widerruft:
[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
Beide Listen in einer Datei, und die ganze Kette besteht die Prüfung:
$ 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
Trag dir einen Termin in einem Jahr in den Kalender. Läuft die Root-CRL ab, ist jedes Telefon wieder ausgesperrt.
Die CRL veröffentlichen
Die CRL muss dort liegen, wohin die URL in jedem Zertifikat zeigt. Nicht auf der CA selbst.
| Entscheidung | Warum |
|---|---|
| Ein Webserver davor, nicht Port 9000 | Port 9000 ist die komplette API von step-ca, Signieren eingeschlossen. Niemand braucht davon etwas außer der Liste |
| Einfaches HTTP, nicht HTTPS | RFC 5280 sagt, ein Verteilungspunkt „SHOULD include at least one LDAP or HTTP URI“6. Ein Abruf über HTTPS braucht eine Zertifikatsprüfung, die die Liste braucht. HTTP durchbricht den Kreis |
| Keine Sorge wegen Manipulation | Die Liste ist signiert. Ändere ein Byte, und die Signatur stimmt nicht mehr |
| Nur die zwei CRL-Pfade antworten | Alles andere ist ein 404 |
Beide Webserver schaffen das in ein paar Zeilen, und beide reichen die Liste direkt von step-ca durch und liefern die Root-CRL als Datei aus:
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
}
}
| Anfrage | nginx | Caddy |
|---|---|---|
/1.0/crl | 200, application/pkix-crl, Aussteller die Zwischen-CA | 200, application/pkix-crl, Aussteller die Zwischen-CA |
/root.crl | 200, application/pkix-crl | 200, application/pkix-crl |
/roots.pem, /1.0/sign und der Rest der API | 404 | 404 |
Beide antworten auf IPv6 genauso wie auf IPv4, und das sollen sie auch. Caddy lauscht standardmäßig auf beiden. nginx lauscht nur dort, wo man es ihm sagt, also ist listen 80; allein nur IPv4 und braucht listen [::]:80; daneben. Getestet über ::1 kam die Liste von beiden mit 200 zurück, sobald nginx diese Zeile hatte. step-ca selbst lauscht ab Werk auf beiden.
Der Test lief auf den Ports 8080 und 8081, also trug die URL in den Testzertifikaten einen Port. Im Betrieb ist es Port 80, und die URL ist genau das, was in der Vorlage steht.
Frisch halten
Jede CRL, die step-ca erstellt, trägt ein nextUpdate 24 Stunden in der Zukunft. Danach ist sie abgelaufen, und eine abgelaufene Liste scheitert genauso wie eine fehlende:
$ 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
Das ist das gute Telefon. Zwei Tage später. Ausgesperrt, weil nichts eine neue Liste geholt hat.
nginx liest ssl_crl nur beim Start oder Reload. Also muss etwas alle paar Stunden die Liste holen, die Datei neu bauen und nginx neu laden, solange das hier läuft, ob jemand hinschaut oder nicht.
#!/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}
| Lauf | Ergebnis |
|---|---|
| Normal | Neue Liste an Ort und Stelle, nginx neu geladen, phone1 200, widerrufenes phone2 400 |
| CA nicht erreichbar | curl: (22) The requested URL returned error: 404, Exit 22, alte Liste unangetastet, kein Reload |
set -e erledigt die eigentliche Arbeit. Ein gescheiterter Abruf darf niemals eine leere Datei über eine gute schreiben, denn eine kaputte Liste sperrt alle genauso sicher aus wie eine veraltete. Lass es alle paar Stunden aus einem systemd-Timer oder Cron laufen. Das gibt ihm mehrere Anläufe, bevor etwas abläuft. Nichts Raffiniertes, und es ist das Stück, das die Tür funktionieren lässt.
Einem Telefon sein Zertifikat ausstellen
Ein Zertifikat pro Telefon, nach dem Telefon benannt, für ein Jahr:
step ca certificate phone1 phone1.crt phone1.key \
--provisioner admin --provisioner-password-file prov-pass \
--not-after 8760h --kty EC
Das Telefon will das Zertifikat, seinen Schlüssel und die Zwischen-CA in einer PKCS12-Datei, und step baut die selbst7:
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
Das ist die moderne Verschlüsselung, und genau die soll man laut der Doku der Home Assistant Companion App zuerst probieren, bevor man auf einen Legacy-Container zurückgreift8.
Das Telefon braucht drei Dinge von der CA, nicht eins:
| Datei | Installieren als | Warum |
|---|---|---|
root_ca.crt | CA-Zertifikat | Damit das Telefon dem Serverzertifikat vertraut, das step-ca für den Proxy ausgestellt hat |
intermediate_ca.crt | CA-Zertifikat | Die Kette zwischen dem Root und jedem Zertifikat, das die CA tatsächlich signiert |
phone1.p12 | VPN- und App-Benutzerzertifikat | Der eigene Schlüssel des Telefons, das, womit es hineinkommt |
Die Home-Assistant-App für Android vertraut neben den eingebauten auch den CA-Zertifikaten, die der Benutzer installiert9, also funktioniert ein Serverzertifikat von step-ca. Für diese Aufgabe ist es auch das bessere Schloss. Der Hostname spielt nur im Haus und für die Telefone eine Rolle, und ein Gerät ohne deinen Root vertraut nicht einmal dem Server, geschweige denn, dass es an ihm vorbeikommt.
Wenn das Telefon verschwindet:
step ca revoke --cert phone2.crt --key phone2.key --reason "phone lost"
Certificate with Serial Number 89334534279272070053632987406394420097 has been revoked.
Die neue Liste wird auf der Stelle gebaut, und der nächste Refresh stellt sie vor nginx.
Eine CA ist ein Versprechen, das du jeden Tag hältst
Was mir beim Betrieb aufgefallen ist: wie wenig von der Arbeit das Ausstellen von Zertifikaten ist. Ein Befehl. Das war’s. Die Arbeit steckt ganz in den Versprechen drumherum: dass ein widerrufenes Telefon draußen bleibt, dass ein gutes Telefon drin bleibt, dass die Liste nie veraltet ist. step-ca hält das erste und überlässt dir den Rest, und seine Voreinstellungen gehen davon aus, dass du eine Flotte von Servern bist, die sich jeden Morgen selbst erneuern.
Das ist kein Fehler, das ist die Zielgruppe, für die es gebaut wurde. Aber es ist die Lücke zwischen dem, was ein Werkzeug tut, und dem, als was es verkauft wird. „Betreib deine eigene CA“ klingt nach einem Container und einem Nachmittag. Es ist ein Container, ein Nachmittag, eine Vorlage, eine Root-CRL mit einem Termin im Kalender und ein Timer, dessen Existenz du vergisst bis zu dem Tag, an dem er stehen bleibt.
Mach es trotzdem. Eine CA, die du selbst betreibst, kann niemand sonst abschalten, neu bepreisen oder mitlesen. Wisse nur, dass die Schlüssel zu besitzen heißt, auch die Pflichten zu besitzen, und die Pflichten sind es, die die Tür zu halten.
Smallstep — step-ca configuration —
maxTLSCertDuration,defaultTLSCertDuration,minTLSCertDuration; dercrl-Block ist „(experimental)“,cacheDuration„Defaults to 24h“. ↩︎ ↩︎ ↩︎smallstep/certificates v0.30.2 — authority/tls.go — der Issuing Distribution Point der CRL ist
idpURL, falls gesetzt, sonst der eigene Name der CA plus/1.0/crl, als kritisch markiert. ↩︎ ↩︎Smallstep — Certificate templates —
crlDistributionPoints„defines URLs with the certificate revocation list“. ↩︎nginx — src/event/ngx_event_openssl.c —
ssl_crlsetztX509_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 — Abschnitt 4.2.1.13: der DistributionPointName „SHOULD include at least one LDAP or HTTP URI“. ↩︎
Smallstep — step certificate p12 — verpackt ein Zertifikat, seinen Schlüssel und CA-Zertifikate in eine PKCS12-Datei. ↩︎
Home Assistant Companion — Networking, TLS Client Authentication — zuerst die aktuelle PKCS12-Voreinstellung probieren, den OpenSSL-Container mit
-legacynur, wenn das scheitert. ↩︎home-assistant/android — network_security_config.xml — die Vertrauensanker sind
systemplususer: „Additionally trust user added CAs“. ↩︎