Waarom telefoons het lastige geval zijn
De standaarden van step-ca zijn gebouwd voor servers. Een server vernieuwt zichzelf elke ochtend, en Je eigen certificaatautoriteit: step-ca in een container laat zien dat dat werkt voor ACME-clients en voor al het andere dat step ca renew kan draaien. Dat bericht behandelt ook het starten van de CA, de twee wachtwoorden en het vertrouwen van de root, en dit bericht gaat daar verder. Een telefoon met een clientcertificaat voor de Home Assistant-app kan niets vernieuwen, en als er een kwijtraakt moet je hem vandaag buitensluiten, niet pas als zijn certificaat afloopt. Vijf standaardinstellingen staan in de weg, en geen ervan maakt lawaai. Ze laten je gewoon zitten met certificaten die morgen sterven en een intrekkingslijst die iedereen weigert.
| Standaard | Wat het met een telefoon doet | Oplossing |
|---|---|---|
| Certificaten duren hooguit 24 uur | Het certificaat van de telefoon sterft morgen, en de Home Assistant-app kan het niet vernieuwen | Verhoog maxTLSCertDuration in ca.json |
| CRL uitgeschakeld | Een kwijtgeraakte telefoon intrekken verandert nergens iets | Zet het crl-blok aan |
| CRL-scope klopt niet met de certificaten | nginx weigert elke telefoon, ook de goede | Zet idpURL en voeg een sjabloon met crlDistributionPoints toe |
| Geen CRL voor de intermediate | nginx weigert nog steeds elke telefoon | Onderteken eens per jaar een lege root-CRL |
| CRL duurt 24 uur | Als die verloopt, weigert nginx weer elke telefoon | Ververs hem op een timer |
Cloudflare doet op zijn gratis abonnement de CA-helft hiervan voor je, zoals de Cloudflare-versie laat zien. Het zelf doen betekent alle vijf doen. De tests hieronder draaiden op een CA met de naam Home Assistant CA, en dat is de naam in de uitvoer.
Standaard één: certificaten duren een dag
Vraag om een jaar en de CA zegt nee:
The request was forbidden by the certificate authority: requested duration of
8760h1m0s is more than the authorized maximum certificate duration of 24h1m0s.
Voor servers en ACME-clients is dat de juiste standaard, want die vernieuwen de hele dag zonder dat iemand het merkt. Een telefoon is de uitzondering. De Home Assistant-app kan zijn eigen clientcertificaat niet vernieuwen, dus een certificaat van 24 uur betekent elke dag naar elke telefoon lopen.
De geldigheidsduur is een instelling, en die staat in step-ca’s eigen config. Elke provisioner in ca.json neemt claims1:
"claims": {
"maxTLSCertDuration": "8760h",
"defaultTLSCertDuration": "24h"
}
| Claim | Betekenis | Ingesteld op |
|---|---|---|
maxTLSCertDuration | Het langste certificaat dat deze provisioner ondertekent | 8760h, een jaar |
defaultTLSCertDuration | Wat je krijgt zonder te vragen | Gelaten op 24h, zodat een jaar altijd een bewuste keuze is |
minTLSCertDuration | Het kortste dat hij ondertekent | Met rust gelaten |
Herstart de container en een jaar gaat erdoor. Een jaar is een afweging, geen regel. Korter betekent minder tijd waarin een gestolen certificaat bruikbaar is, en meer rondjes door het huis om ze opnieuw te installeren.
Standaard twee: de CRL staat uit
Een certificaatintrekkingslijst (CRL) is de ondertekende lijst van de CA met certificaten die ze heeft ingetrokken. Zo wordt een kwijtgeraakte telefoon buitengesloten voordat zijn certificaat verloopt, en step-ca maakt er geen tenzij je dat zegt. De documentatie noemt de functie experimenteel1.
"crl": {
"enabled": true,
"generateOnRevoke": true,
"cacheDuration": "24h"
}
| Veld | Doet | Standaard |
|---|---|---|
enabled | Maakt überhaupt een CRL | Uit |
generateOnRevoke | Bouwt de lijst opnieuw op het moment dat een certificaat wordt ingetrokken | Uit |
cacheDuration | Hoe lang elke lijst geldig blijft | 24 uur1 |
idpURL | De URL waarvan de lijst zegt dat ze die dekt | De eigen naam van de CA plus /1.0/crl2 |
Onthoud die 24 uur. Die komt terug.
Standaard drie: een CRL die niets accepteert
Met de CRL aan, trek een telefoon in en controleer beide telefoons ertegen:
$ 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 falen, de goede telefoon inbegrepen. Zet die lijst voor nginx en niemand komt erin.
De lijst zelf zegt waarom. step-ca markeert elke CRL met een kritieke Issuing Distribution Point, die zegt “deze lijst dekt certificaten die hierheen wijzen”. De certificaten die hij uitgaf wijzen nergens heen:
$ 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 past geen lijst toe waarvan de scope niet bij het certificaat past. Hij faalt dus dicht. Dat is juist, en niemand heeft er iets aan.
| De CRL zegt dat ze dekt | Het certificaat zegt dat zijn CRL staat op | Resultaat | |
|---|---|---|---|
| Standaard | https://localhost/1.0/crl, kritiek | Nergens | different CRL scope, iedereen geweigerd |
idpURL en een sjabloon | http://ca.home.example/1.0/crl | http://ca.home.example/1.0/crl | Goede telefoons komen erdoor, ingetrokken telefoons falen |
De standaard-URL is niet eens een adres waarop de CA serveert. De broncode van step-ca zegt het zelf: “this is currently using the port 443 by default”2. Twee instellingen lossen het op, en beide moeten dezelfde URL noemen. idpURL gaat in het crl-blok van ca.json, en een sjabloon op de provisioner zet het overeenkomende crlDistributionPoints in elk certificaat3:
{
"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" } }
Geef de certificaten van de telefoons opnieuw uit, want alleen nieuwe certificaten dragen het veld, en dezelfde controle geeft het juiste antwoord:
CN=phone2
error 23 at 0 depth lookup: certificate revoked
error phone2.crt: verification failed
phone1.crt: OK
Ook de root heeft een CRL nodig
Die controle keek alleen naar het certificaat van de telefoon. nginx kijkt naar de hele keten. Het zet OpenSSL’s vlag CRL_CHECK_ALL4, en de documentatie zegt het ronduit: “When using intermediate certificates, their CRLs should be specified in the same file”5. step-ca geeft certificaten uit vanaf zijn intermediate en somt alleen ooit de intrekkingen van de intermediate op. Niets zegt of de intermediate zelf nog in orde is:
$ 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
De oplossing is een door de root ondertekende CRL die niets intrekt. OpenSSL maakt er een met de rootsleutel en het eigen wachtwoordbestand van de CA, en hij kan een jaar duren omdat de root van dag tot dag niets intrekt:
[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 lijsten in één bestand, en de hele keten klopt:
$ 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
Zet een datum over een jaar in de agenda. Als de root-CRL verloopt, is elke telefoon weer buitengesloten.
De CRL publiceren
De CRL moet staan waar de URL in elk certificaat naar wijst. Niet op de CA zelf.
| Keuze | Waarom |
|---|---|
| Een webserver ervoor, niet poort 9000 | Poort 9000 is de hele API van step-ca, ondertekenen inbegrepen. Niemand heeft daar iets van nodig behalve de lijst |
| Gewone HTTP, geen HTTPS | RFC 5280 zegt dat een distributiepunt “SHOULD include at least one LDAP or HTTP URI”6. Ophalen over HTTPS vereist een certificaatcontrole, en die vereist de lijst. HTTP doorbreekt de cirkel |
| Geen zorgen om knoeien | De lijst is ondertekend. Verander één byte en de handtekening faalt |
| Alleen de twee CRL-paden antwoorden | Al het andere is een 404 |
Beide webservers doen het in een paar regels, en beide geven de lijst rechtstreeks door van step-ca en serveren de root-CRL als bestand:
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
}
}
| Verzoek | nginx | Caddy |
|---|---|---|
/1.0/crl | 200, application/pkix-crl, uitgever de intermediate | 200, application/pkix-crl, uitgever de intermediate |
/root.crl | 200, application/pkix-crl | 200, application/pkix-crl |
/roots.pem, /1.0/sign en de rest van de API | 404 | 404 |
Beide antwoorden op IPv6 en op IPv4, en zo hoort het. Caddy luistert standaard op beide. nginx luistert alleen waar het wordt gezegd, dus listen 80; alleen is uitsluitend IPv4 en heeft listen [::]:80; ernaast nodig. Getest over ::1 kwam de lijst van beide terug met 200, zodra nginx die regel had. step-ca zelf luistert standaard op beide.
De test draaide op poorten 8080 en 8081, dus de URL in de testcertificaten had een poortnummer. In gebruik is het poort 80 en is de URL precies wat het sjabloon zegt.
Hem vers houden
Elke CRL die step-ca maakt heeft een nextUpdate van 24 uur verderop. Daarna is hij verlopen, en een verlopen lijst faalt op dezelfde manier als een ontbrekende:
$ 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
Dat is de goede telefoon. Twee dagen later. Buitengesloten, omdat niets een nieuwe lijst ophaalde.
nginx leest ssl_crl alleen bij het starten of herladen. Er moet dus iets de lijst ophalen, het bestand opnieuw opbouwen en nginx om de paar uur herladen, zolang dit draait, of er nu iemand kijkt of niet.
#!/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}
| Run | Resultaat |
|---|---|
| Normaal | Nieuwe lijst op zijn plek, nginx herladen, phone1 200, ingetrokken phone2 400 |
| CA onbereikbaar | curl: (22) The requested URL returned error: 404, exit 22, oude lijst ongemoeid, geen reload |
set -e doet het echte werk. Een mislukte ophaling mag nooit een leeg bestand over een goed bestand schrijven, want een kapotte lijst sluit iedereen net zo zeker buiten als een verouderde. Draai het om de paar uur vanuit een systemd-timer of cron. Dan heeft het meerdere pogingen voordat er iets verloopt. Niks slims, en het is het stuk dat de deur laat werken.
Een telefoon zijn certificaat geven
Eén certificaat per telefoon, vernoemd naar de telefoon, voor een jaar:
step ca certificate phone1 phone1.crt phone1.key \
--provisioner admin --provisioner-password-file prov-pass \
--not-after 8760h --kty EC
De telefoon wil het certificaat, de sleutel en de intermediate in één PKCS12-bestand, en step bouwt dat zelf7:
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
Dat is de moderne versleuteling, en de documentatie van de Home Assistant Companion zegt die eerst te proberen, voordat je terugvalt op een legacy-container8.
De telefoon heeft drie dingen van de CA nodig, niet één:
| Bestand | Installeren als | Waarom |
|---|---|---|
root_ca.crt | CA-certificaat | Zodat de telefoon het servercertificaat vertrouwt dat step-ca voor de proxy uitgaf |
intermediate_ca.crt | CA-certificaat | De keten tussen de root en elk certificaat dat de CA werkelijk ondertekent |
phone1.p12 | Gebruikerscertificaat voor VPN en apps | De eigen sleutel van de telefoon, het ding dat hem binnenlaat |
De Android-app van Home Assistant vertrouwt CA-certificaten die de gebruiker installeert naast de ingebouwde9, dus een servercertificaat van step-ca werkt. Voor deze taak is het ook het betere slot. De hostnaam doet er alleen binnen het huis en voor de telefoons toe, en een apparaat zonder jouw root vertrouwt de server niet eens, laat staan dat het erlangs komt.
Als de telefoon kwijtraakt:
step ca revoke --cert phone2.crt --key phone2.key --reason "phone lost"
Certificate with Serial Number 89334534279272070053632987406394420097 has been revoked.
De nieuwe lijst wordt ter plekke gebouwd, en de volgende verversing zet hem voor nginx.
Een CA is een belofte die je elke dag nakomt
Toen ik dit draaide, viel me op hoe weinig van het werk het uitgeven van certificaten is. Eén commando. Dat is alles. Het werk zit in de beloften eromheen: dat een ingetrokken telefoon buiten blijft, dat een goede telefoon binnen blijft, dat de lijst nooit verouderd is. step-ca houdt de eerste en laat de rest aan jou over, en de standaarden gaan ervan uit dat je een vloot servers bent die zichzelf elke ochtend vernieuwen.
Dat is geen fout, het is voor wie hij gebouwd is. Maar het is het gat tussen wat een gereedschap doet en waarvoor het wordt verkocht. “Draai je eigen CA” klinkt als een container en een middag. Het is een container, een middag, een sjabloon, een root-CRL met een datum in de agenda, en een timer waarvan je vergeet dat hij bestaat tot de dag dat hij stopt.
Doe het toch. Een CA die je zelf draait is er een die niemand anders kan uitzetten, duurder kan maken of kan lezen. Weet alleen dat de sleutels bezitten betekent de klusjes bezitten, en de klusjes zijn wat de deur dicht houdt.
Smallstep — step-ca configuration —
maxTLSCertDuration,defaultTLSCertDuration,minTLSCertDuration; hetcrl-blok is “(experimental)”,cacheDuration“Defaults to 24h”. ↩︎ ↩︎ ↩︎smallstep/certificates v0.30.2 — authority/tls.go — het Issuing Distribution Point van de CRL is
idpURLals die is gezet, anders de eigen naam van de CA plus/1.0/crl, gemarkeerd als kritiek. ↩︎ ↩︎Smallstep — Certificate templates —
crlDistributionPoints“defines URLs with the certificate revocation list”. ↩︎nginx — src/event/ngx_event_openssl.c —
ssl_crlzetX509_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 — sectie 4.2.1.13: de DistributionPointName “SHOULD include at least one LDAP or HTTP URI”. ↩︎
Smallstep — step certificate p12 — verpakt een certificaat, de sleutel en CA-certificaten in een PKCS12-bestand. ↩︎
Home Assistant Companion — Networking, TLS Client Authentication — probeer eerst de huidige PKCS12-standaard, en de OpenSSL-container
-legacyalleen als dat faalt. ↩︎home-assistant/android — network_security_config.xml — vertrouwensankers zijn
systemplususer: “Additionally trust user added CAs”. ↩︎