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.

VoreinstellungWas sie mit einem Telefon machtLösung
Zertifikate gelten höchstens 24 StundenDas Zertifikat des Telefons stirbt morgen, und die Home-Assistant-App kann es nicht erneuernmaxTLSCertDuration in ca.json anheben
CRL ausgeschaltetEin verlorenes Telefon zu widerrufen ändert nirgendwo etwasDen crl-Block einschalten
Geltungsbereich der CRL passt nicht zu den Zertifikatennginx weist jedes Telefon ab, auch die gutenidpURL setzen und eine Vorlage mit crlDistributionPoints hinzufügen
Keine CRL für die Zwischen-CAnginx weist immer noch jedes Telefon abEinmal im Jahr eine leere Root-CRL signieren
CRL gilt 24 StundenLäuft sie ab, weist nginx wieder jedes Telefon abPer 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"
}
ClaimBedeutungGesetzt auf
maxTLSCertDurationDas längste Zertifikat, das dieser Provisioner signiert8760h, ein Jahr
defaultTLSCertDurationWas du bekommst, ohne zu fragenBei 24h belassen, damit ein Jahr immer eine bewusste Entscheidung ist
minTLSCertDurationDas kürzeste, das er signiertUnverä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"
}
FeldBewirktVoreinstellung
enabledDass überhaupt eine CRL entstehtAus
generateOnRevokeBaut die Liste in dem Moment neu, in dem ein Zertifikat widerrufen wirdAus
cacheDurationWie lange jede Liste gültig bleibt24 Stunden1
idpURLDie URL, für die die Liste nach eigener Aussage giltDer 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ürDas Zertifikat sagt, seine CRL liegt unterErgebnis
Ab Werkhttps://localhost/1.0/crl, kritischNirgendsdifferent CRL scope, alle abgewiesen
idpURL und eine Vorlagehttp://ca.home.example/1.0/crlhttp://ca.home.example/1.0/crlGute 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
Welche CRL welches Zertifikat abdecktRoot-CAvertraut, wie er istZwischen-CAsigniert jedes Zertifikatphone1gutphone2widerrufenServerha.example.comsigniertnginx prüft jedes Gliedein Glied ohne CRL wird abgewiesenRoot-CRL365 Tage, von OpenSSLCRL von step-ca24 Stunden, führt phone2deckt abdeckt ab
Jedes Glied unter dem Root braucht eine CRL vom Zertifikat darüber. step-ca deckt die untere Reihe ab. Nichts deckt die Zwischen-CA ab, bis du selbst eine Root-CRL signierst.

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.

EntscheidungWarum
Ein Webserver davor, nicht Port 9000Port 9000 ist die komplette API von step-ca, Signieren eingeschlossen. Niemand braucht davon etwas außer der Liste
Einfaches HTTP, nicht HTTPSRFC 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 ManipulationDie Liste ist signiert. Ändere ein Byte, und die Signatur stimmt nicht mehr
Nur die zwei CRL-Pfade antwortenAlles 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
	}
}
AnfragenginxCaddy
/1.0/crl200, application/pkix-crl, Aussteller die Zwischen-CA200, application/pkix-crl, Aussteller die Zwischen-CA
/root.crl200, application/pkix-crl200, application/pkix-crl
/roots.pem, /1.0/sign und der Rest der API404404

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.

Die CRL veröffentlichen und frisch haltenstep-ca:9000, ganze APIbleibt drinnennginx oder CaddyPort 80, v4 und v6/1.0/crl, /root.crlalles andere 404Liste läuft abnach 24 Stunden,jedes Telefon abgewiesenTimeralle paar StundenmTLS-Proxyssl_crl, neu geladenCRLholenneu ladenstoppt
Die API von step-ca bleibt drinnen. Der Webserver gibt nur die zwei Listen heraus, und ein Timer holt die frische und lädt den Proxy neu. Stoppt der Timer, läuft die Liste einen Tag später ab und sperrt jedes Telefon aus.

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}
LaufErgebnis
NormalNeue Liste an Ort und Stelle, nginx neu geladen, phone1 200, widerrufenes phone2 400
CA nicht erreichbarcurl: (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:

DateiInstallieren alsWarum
root_ca.crtCA-ZertifikatDamit das Telefon dem Serverzertifikat vertraut, das step-ca für den Proxy ausgestellt hat
intermediate_ca.crtCA-ZertifikatDie Kette zwischen dem Root und jedem Zertifikat, das die CA tatsächlich signiert
phone1.p12VPN- und App-BenutzerzertifikatDer 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.


  1. Smallstep — step-ca configuration — maxTLSCertDuration, defaultTLSCertDuration, minTLSCertDuration; der crl-Block ist „(experimental)“, cacheDuration „Defaults to 24h“. ↩︎ ↩︎ ↩︎

  2. 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. ↩︎ ↩︎

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

  4. nginx — src/event/ngx_event_openssl.c — ssl_crl setzt 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 — Abschnitt 4.2.1.13: der DistributionPointName „SHOULD include at least one LDAP or HTTP URI“. ↩︎

  7. Smallstep — step certificate p12 — verpackt ein Zertifikat, seinen Schlüssel und CA-Zertifikate in eine PKCS12-Datei. ↩︎

  8. Home Assistant Companion — Networking, TLS Client Authentication — zuerst die aktuelle PKCS12-Voreinstellung probieren, den OpenSSL-Container mit -legacy nur, wenn das scheitert. ↩︎

  9. home-assistant/android — network_security_config.xml — die Vertrauensanker sind system plus user: „Additionally trust user added CAs“. ↩︎