Vom PEM zum laufenden Stack – Teil 4: Wenn ein Dienst zwei Welten vertrauen muss
Das Finale der Serie. Die Zertifikate waren sauber, die Passwörter vereinheitlicht, die Pfade korrekt. Ein Dienst startete trotzdem nicht – und der Grund war diesmal kein Fehler, sondern ein Denkfehler über die Architektur von Vertrauen.
Ein Fortschritt, der wie ein Rückschritt aussah
Nachdem die Middleware endlich dem internen Auth-Server vertraute, kam ein neuer Vertrauensfehler. Auf den ersten Blick frustrierend – schon wieder PKIX. Aber der Issuer im Fehler hatte sich verändert:
Before: https://keycloak.example.local:5443/... <- the internal auth server Now: https://auth-provider.example.com/... <- a public cloud service
Das war in Wahrheit ein Erfolg: Dem internen Server wurde nicht mehr misstraut. Der Fehler betraf jetzt einen zweiten Authentifizierungsanbieter – einen öffentlichen Cloud-Dienst, der für ein kostenpflichtiges Zusatzmodul gebraucht wurde.
Lektion: Wenn sich bei einem hartnäckigen Fehler ein Detail ändert (hier: der Hostname im Fehler), ist das ein Hinweis, dass die vorherige Hürde gefallen ist. Lies Fehlermeldungen genau – die Unterschiede sind die Information.
Das eigentliche Problem: Ein zu enger Truststore
Die Konfiguration band den Dienst über eine Java-Option an meinen selbstgebauten Truststore:
-Djavax.net.ssl.trustStore=/mw/root.p12
Hier liegt eine Eigenschaft von Java, die man kennen muss: Sobald man einen eigenen Truststore setzt, ersetzt er den Standard-Truststore komplett. Der Dienst vertraute also ausschließlich meiner internen CA – und keiner einzigen öffentlichen CA mehr.
Für die interne Verbindung zum Auth-Server war das perfekt. Aber der öffentliche Cloud-Anbieter benutzt ein Zertifikat einer ganz normalen öffentlichen CA (wie sie jeder Browser kennt). Die fehlte jetzt – also Vertrauensbruch.
Die Lösung: Beide Welten in einem Truststore
Die Anforderung war klar: Der Dienst muss gleichzeitig meiner internen CA und den öffentlichen CAs vertrauen. Die saubere Lösung ist, Javas mitgelieferten Standard-Truststore (cacerts, der bereits alle öffentlichen CAs enthält) als Basis zu nehmen und die eigene CA hinzuzufügen.
Der Standard-cacerts liegt im JDK und hat üblicherweise das Passwort changeit. Da unser Dienst aber einen anderen Wert erwartet, müssen wir beim Kopieren das Store-Passwort umstellen und dann unsere CA importieren – am besten im Container, wo cacerts und keytool ohnehin liegen:
docker compose run --rm -v /host/ssl:/out:ro --entrypoint sh rest-api -c '
CACERTS=$(find / -name cacerts 2>/dev/null | head -1)
cp "$CACERTS" /tmp/work.p12
# switch store password from "changeit" to our value
keytool -importkeystore -noprompt \
-srckeystore /tmp/work.p12 -srcstorepass changeit \
-destkeystore /tmp/root.p12 -deststorepass <TRUSTSTORE_PW> -deststoretype PKCS12
# add our own CA
keytool -importcert -noprompt -alias corp-ca \
-file /out/corp-ca-root.pem \
-keystore /tmp/root.p12 -storepass <TRUSTSTORE_PW>
cat /tmp/root.p12
' > /host/installation/certs/root.p12
Die Verifikation zeigte das Ziel erreicht:
keytool -list -keystore root.p12 -storetype PKCS12 -storepass <TRUSTSTORE_PW> \ | grep -iE 'entries|corp-ca' # -> Your keystore contains 151 entries # -> corp-ca, ..., trustedCertEntry
151 Einträge: die ~150 öffentlichen Standard-CAs plus meine eine interne CA. Damit vertraut der Dienst beiden Welten. Und tatsächlich – der nächste Start brachte die ersehnte Zeile:
Tomcat started on port 8080 (http) with context path '/mw'
Lektion: Ein eigener Java-Truststore ersetzt den Standard, er ergänzt ihn nicht. Wenn ein Dienst sowohl interne als auch öffentliche Ziele erreicht, baue den Truststore aus cacerts als Basis plus deiner CA – sonst sperrst du versehentlich die halbe Welt aus.
Als sei das ein letzter Test, hängte sich auch der nginx-Webserver auf:
nginx: [emerg] cannot load certificate "/etc/nginx/certs/issued.crt": PEM_read_bio_X509_AUX() failed ... wrong tag ... nested asn1 error
Das Muster kannten wir aus Teil 1: eine doppelt Base64-kodierte Datei. Doch die eigentliche Lektion hier ist eine andere – nginx tickt grundsätzlich anders als die Java-Dienste.
BEWEIS: Während Keycloak und die Middleware PKCS#12-Container mit Passwort wollten, erwartet nginx schlichtes PEM im Klartext: das Zertifikat als -----BEGIN CERTIFICATE------Blöcke, den Schlüssel als -----BEGIN PRIVATE KEY-----, ohne Passwort und ohne Container-Verpackung.
Praktischerweise konnte ich beides aus dem bereits funktionierenden PKCS#12-Server-Keystore extrahieren:
# full chain (server certificate + CA) as PEM openssl pkcs12 -in issued.p12 -passin pass:<TRUSTSTORE_PW> -nokeys -out full_chain.pem # private key as unencrypted PEM openssl pkcs12 -in issued.p12 -passin pass:<TRUSTSTORE_PW> -nocerts -nodes -out private.key
Ein kleiner Stolperstein zum Schluss: OpenSSL schreibt vor die PEM-Blöcke gern Bag Attributes-Klartextzeilen, über die manche nginx-Versionen stolpern. Sauber schneidet man sie weg:
sed -n '/-----BEGIN CERTIFICATE-----/,/-----END CERTIFICATE-----/p' full_chain_raw.pem > full_chain.pem
Und – fast am wichtigsten – immer prüfen, dass Zertifikat und Schlüssel ein zusammengehöriges Paar sind. Die beiden Hashes müssen identisch sein:
openssl x509 -in full_chain.pem -noout -modulus | openssl md5 openssl rsa -in private.key -noout -modulus | openssl md5
URTEIL: Sind sie verschieden, hat man zwar lesbare Dateien, aber TLS scheitert trotzdem – ein Fehler, der sonst erst beim Handshake auffällt.
Das große Fazit
Nach einem Tag voller Fehlermeldungen lässt sich die Essenz in ein paar Prinzipien gießen, die weit über dieses eine Projekt hinaus gelten:
- „Zertifikat" ist kein einheitliches Ding. PEM, DER, PKCS#12, Truststore, Keystore, CA-Wurzel, Endzertifikat – das sind verschiedene Dinge mit verschiedenen Regeln. Die meiste verlorene Zeit entsteht, wenn man sie verwechselt.
- Die Dateiendung lügt. Was wirklich in einer Datei steckt, sagen dir
file,head,openssl x509 -textundkeytool -list– nicht der Name. - Verschiedene Werkzeuge haben verschiedene Wahrheiten. Dass OpenSSL eine Datei akzeptiert, heißt nicht, dass Java es tut. Verifiziere mit dem Werkzeug, das die Datei am Ende benutzt.
- Fehlermeldungen sind Hinweise, keine Urteile. „Passwort falsch" hieß in Wahrheit „falscher Algorithmus". „Connection refused" hieß „Dienst läuft nicht". Lies sie wörtlich und hinterfrage, was sie technisch bedeuten.
- Ändert sich nach einer Änderung nichts, war die Änderung am falschen Ort. Diese eine Erkenntnis hätte mir Stunden gespart. Prüfe Pfad, Datei und Wert, bevor du dieselbe Reparatur wiederholst.
- Verifiziere über Fingerprints, nicht über Namen. Ein Alias oder Dateiname beweist nichts. Der Fingerabdruck eines Zertifikats ist die einzige verlässliche Identität.
- Verstehe die Vertrauensarchitektur, nicht nur die Befehle. Ein eigener Truststore ersetzt den Standard. Ein Truststore braucht die CA, kein Endzertifikat. Verschiedene Dienste erwarten verschiedene Formate. Wer das Modell versteht, braucht die Befehle kaum noch auswendig.
Am Ende lief der komplette Stack: der Auth-Server, die Middleware mit ihren zwei Vertrauensankern, der Webserver. Eine Reise von einer einzigen .pem-Datei bis zu einem Dutzend zusammenspielender Container – und jede Hürde hat etwas beigebracht, das beim nächsten Mal nur noch Minuten kostet.
Ende der Serie. Danke fürs Mitlesen – und möge dein nächster Zertifikatsfehler ein lehrreicher sein.