Eigene TLS-Zertifikate in Java-Anwendungen: Alles was du zum TrustStore wissen solltest

Eigene TLS-Zertifikate in Java-Anwendungen: Alles was du zum TrustStore wissen solltest

Der TrustStore von Java wird wichtig, wenn man eigene (Root-) Zertifikate dort hinterlegen möchte oder muss. Dies ist vor allem für zwei Umgebungen interessant: Testsysteme, auf denen selbst signierte Zertifikate liegen. Und Unternehmensumgebungen, in denen man mit eigenen Zertifizierungsstellen arbeitet und/oder sogar Proxy-Servern ausgesetzt ist.

Wofür wird der Java TrustStore benötigt?

TLS kennen die meisten wahrscheinlich als Transportverschlüsselung von HTTPS: Die Meisten Webseiten verschlüsseln damit den Datenaustausch zum Zielserver, damit unterwegs nichts ausspioniert oder manipuliert werden kann. Es gibt noch weitere Protokolle, welche ursprünglich unverschlüsselt waren und später mit TLS um Transportverschlüsselung ergänzt wurden: SMTP ~> SMTPS, IMAP ~> IMAPS, FTP ~> FTPS und einige mehr.

In jedem Falle kommen Zertifikate bei TLS zum Einsatz, denen der Client vertrauen muss. Im Regelfall geschieht dies über eine Vertrauenskette: Der Client vertraut entweder der Zertifizierungsstelle, die das Zertifikat ausgestellt hat. Große Zertifizierungsstellen wie beispielsweise Let’s Encrypt sind in gängigen Betriebssystemen & Programmen bereits hinterlegt. Betreibt ein Unternehmen eine eigene, interne Zertifizierungsstelle, ist das nicht der Fall – sie muss händisch hinterlegt werden. Auch einem selbst signiertes (Test-) Zertifikat wird nicht vertraut.

Es gibt hierfür einen Trust Store im Betriebssystem. Unter Ubuntu liegen etwa alle (Root-) Zertifikate unter /usr/local/share/ca-certificates, denen vertraut wird. Einige (v.a. plattformübergreifende) Anwendungen und Plattformen nutzen diese nicht, sondern besitzen eigene Speicher. Java ist hier ein Beispiel, Wie es dort funktioniert, wird im folgenden gezeigt.

Proxy-Server und MITM

Setzt ein Unternehmen Proxy-Server zur Überwachung/Filterung ein, brechen diese TLS-Verschlüsselungen auf und verschlüsseln neu. Es ist hier also keine Ende-zu-Ende Verschlüsselung bis zum Client mehr, sondern nur bis zum Proxyserver im Unternehmensnetz. Oder gegebenenfalls dem Anbieter, wenn jemand meint, externe Clouddienste wie ZScaler zu nutzen. In jedem Falle lässt sich die Integrität der ursprünglich genutzten Verschlüsselung nicht mehr sicherstellen, weswegen der Client dem Proxyserver vertrauen muss. Bei einem internen Unternehmensdienst reicht die unternehmenseigene Zertifizierungsstelle. Im Falle eines externen Dienstes muss man zusätzlich dessen Root-Zertifikat vertrauen. Ansonsten brechen Clients die Verbindung ab, da nicht vertrauenswürdige Zertifikate bzw. Zertifizierungsstellen als Sicherheitsrisiko gesehen werden.

Wo liegt der TrustStore von Java?

Standardmäßig in $JAVA_HOME/jre/lib/security/cacerts, wobei $JAVA_HOME der Basis-Ordner der Java-Installation ist. Wenn ihr Java als Archiv herunterladet und es entpackt, ist dies (ohne Unterordner für die Version) das Heimverzeichnis. Im Folgenden Beispiel von Adoptium OpenJDK der Inhalt von jdk-11.0.20.1+1 im Archiv:

Bei einer händischen Installation würde man dessen Inhalt in ein beliebiges Verzeichnis (gerne /opt für händisch installierte, optionale Software) entpacken, beispielsweise /opt/openjdk-11.

sudo mkdir /opt/openjdk-11
sudo chown $USER /opt/openjdk-11

tar --strip-components=1 -xf OpenJDK11U-jdk_x64_linux_hotspot_11.0.20.1_1.tar.gz -C /tmp/openjdk-11

Der TrustStore läge dort in /opt/openjdk-11/lib/security/cacerts:

Anders kann es aussehen, wenn man Java über die Paketverwaltung der Distribution installiert. Auf einem Suse Linux Enterprise (SLES) habe ich ihn beispielsweise in /usr/java/latest/lib/security/cacerts gefunden, bzw. in einem Unterordner für die jeweilige Version mit Build unter /usr/java. Der Pfad kann variieren, je nachdem welche GNU/Linux-Distribution und welche Java-Distribution ihr nutzt. Sollte völlige Unklarheit herrschen, kann man sich $JAVA_HOME ausgeben lassen. Ist dies nicht gesetzt, kann man sich den Pfad von Java auch über die Properties ausgeben lassen:

java -XshowSettings:properties -version 2>&1 > /dev/null | grep 'java.home'
    java.home = /usr/lib/jvm/java-21-openjdk

Einsehen, Hinzufügen und ändern von Zertifikaten

Der TrustStore von Java ist keine Textdatei mit einer Zertifikatskette, sondern eine Binärdatei: Seit Java wird PKCS12 verwendet. Sie muss mit keytool bearbeitet werden, was wiederum in $JAVA_HOME/bin liegt. Mit dem Befehl -list kann man sich einen Überblick verschaffen, welche Zertifikate darin liegen. Hier sind es 143 Stück, die Ausgabe mit den Prüfsummen wurde gekürzt. Bei jeder Operation fordert das Keytool zur Eingabe eines Passwortes auf. Dies lautet standardmäßig changeit und wird – wie unter GNU/Linux üblich – beim Tippen nicht angezeigt:

$JAVA_HOME/bin/keytool -list -cacerts -file $JAVA_HOME/lib/security/cacerts
Enter keystore password:
Keystore type: JKS
Keystore provider: SUN
Your keystore contains 143 entries
...

In den meisten Fällen wird man den TrustStore um ein neues (Root-) Zertifikat erweitern wollen, dies funktioniert mit dem Befehl -importcert.

$JAVA_HOME/bin/keytool -importcert -alias meine-ca-2023 -keystore $JAVA_HOME/lib/security/cacerts -file meine-ca-2023.crt
Enter keystore password:
Owner: ...
Trust this certificate? [no]:  yes
Certificate was added to keystore

Das Alias meine-ca-2023 ist eine frei wählbare Bezeichnung, die jedoch innerhalb des TrustStore einzigartig sein muss. Mit -file gebt ihr den Pfad zur Zertifikatsdatei an. Nachdem ihr mit yes bestätigt habt, dem Zertifikat zu vertrauen, wird es importiert. Falls das Zertifikat bereits existiert, aber unter einem anderen Alias importiert wurde, warnt euch das Werkzeug.

Möchte man etwa ein ausgelaufenes Zertifikat entfernen, ist dies mit -delete möglich. Identifiziert wird es anhand des beim hinzufügen definierten Aliases:

$JAVA_HOME/bin/keytool -delete -alias meine-ca-2023 -keystore $JAVA_HOME/lib/security/cacerts

Verwenden des Stores & Fehlersuche

Standardmäßig lädt Java den TrustStore aus dem oben Pfad $JAVA_HOME/jre/lib/security/cacerts. In vielen Fällen ist das auch in Ordnung. Doch es gibt Szenarien (z.B. zum testen), in denen man lieber einen anderen laden möchte. Hierfür gibt es zwei Eigenschaften (Properties), die man an Java jeweils mit -D übergeben kann:

  • javax.net.ssl.trustStore legt den Pfad zur TrustStore-Datei (cacerts) fest, die geladen werden soll
  • javax.net.ssl.trustStorePassword enthält das dazugehörige Password (Standardmäßig changeit)

Zum Start einer Java-Anwendung kann der Aufruf wie folgt aussehen:

java -Djavax.net.ssl.trustStore=/tmp/cacert-test -Djavax.net.ssl.trustStorePassword=changeit WebserviceDemo

Tests mit einfachem HTTP-Aufruf

Java ist nicht gerade als leichtgewichtiges, effiziente Plattform bekannt. Durchaus auch dank Spezialisten wie Atlassian: Die haben es geschafft, relativ einfache Anwendungen wie z.B. Bitbucket so aufzublähen, dass sie mehrere Minuten (!) zum Starten benötigen. Und das auf leistungsstarken Servern mit 6 Xeon-Kernen. Gogs, GitTea & diverse weitere realisieren das in Sekunden auf einem Raspberry Pi, ohne tausende Dollar proprietäre Lizenzgebühren dafür zu verlangen – träge Software ist offensichtlich kein Naturgesetz. Jedenfalls ist es mit solchen Monstern extrem zäh, derartiges zu testen. Statt mich noch mehr über Atlassians Qualität aufzuregen, habe ich mir daher eine simple Klasse geschrieben, die eine HTTP-Abfrage durchführt:

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class WebserviceDemo {       
	public static void main (String[] args) throws IOException, InterruptedException {
        	HttpClient client = HttpClient.newHttpClient();
            	HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create("https://api.ipify.org?format=json"))
                    .build();
	       HttpResponse<String> response = client.send(request,
              HttpResponse.BodyHandlers.ofString());            
	       System.out.println(response.body());
       }
}

Möchte man eine interne CA testen, sollte man die Adresse auf eine anpassen, die ein entsprechendes Zertifikat verwendet. Um den Code zu kompilieren, wird das JDK benötigt. Das JRE kann kompilierte Anwendungen nur ausführen. Falls ihr das Glück habt, an einer Anwendung zu arbeiten, die (richtigerweise) nur das JRE mitbringt: Ermittelt die Version und ladet euch das JDK in der gleichen herunter. Für Tests reicht eine manuelle Installation über das Archiv (siehe oben) völlig aus, die löscht man danach sowieso wieder. Ich nehme in diesem Beispiel die gleiche Version in $JAVA_HOME und kann nun in einer kleinen, kompakten Anwendung sehr effektiv ausprobieren, ob der TrustStore funktioniert.

$JAVA_HOME/bin/javac WebserviceDemo.java
$JAVA_HOME/bin/java -Djavax.net.ssl.trustStore=/tmp/cacert-test -Djavax.net.ssl.trustStorePassword=changeit WebserviceDemo

Fehlersuche mit Debug-Parametern

Sollte es nicht wie gewünscht funktionieren, ist der Parameter -Djavax.net.debug=ssl:trustmanager sehr nützlich: Er zeigt euch sämtliche Debug-Logs zum TrustStoreManager von Java an. Damit könnt ihr beispielsweise herausfinden, wo standardmäßig nach TrustStores gesucht wird:

$JAVA_HOME/bin/java -Djavax.net.debug=ssl:trustmanager WebserviceDemo
javax.net.ssl|DEBUG|01|main|2023-10-17 21:39:18.162 CEST|SSLCipher.java:464|jdk.tls.keyLimits:  entry = AES/GCM/NoPadding KeyUpdate 2^37. AES/GCM/NOPADDING:KEYUPDATE = 137438953472
javax.net.ssl|DEBUG|01|main|2023-10-17 21:39:18.171 CEST|SSLCipher.java:464|jdk.tls.keyLimits:  entry =  ChaCha20-Poly1305 KeyUpdate 2^37. CHACHA20-POLY1305:KEYUPDATE = 137438953472
javax.net.ssl|DEBUG|01|main|2023-10-17 21:39:18.182 CEST|TrustStoreManager.java:161|Inaccessible trust store: /opt/openjdk-11/lib/security/jssecacerts
javax.net.ssl|DEBUG|01|main|2023-10-17 21:39:18.183 CEST|TrustStoreManager.java:112|trustStore is: /opt/openjdk-11/lib/security/cacerts
trustStore type is: pkcs12

Dabei zeigt sich beispielsweise, dass Java einen Fallback auf den Standard TrustStore macht, wenn der per javax.net.ssl.trustStore übergebene nicht existiert. Ohne Debug-Flag erfahrt ihr davon nichts und wundert euch, wieso nichts funktioniert, wenn z.B. der Pfad einen Fehler enthält. Ich hätte in dem Fall einen Fehler erwartet, mindestens aber eine Warnung. Wenn der Parameter ausdrücklich gesetzt ist und dieser nicht geladen werden kann, liegt ein Bedienfehler vor. Das sieht Java offensichtlich anders und generiert daraus lediglich einen Debug-Eintrag:

$JAVA_HOME/bin/java -Djavax.net.ssl.trustStore=/tmp/not-existing -Djavax.net.ssl.trustStorePassword=changeit -Djavax.net.debug=ssl:trustmanager WebserviceDemo
javax.net.ssl|DEBUG|01|main|2023-10-17 21:42:59.702 CEST|SSLCipher.java:464|jdk.tls.keyLimits:  entry = AES/GCM/NoPadding KeyUpdate 2^37. AES/GCM/NOPADDING:KEYUPDATE = 137438953472
javax.net.ssl|DEBUG|01|main|2023-10-17 21:42:59.712 CEST|SSLCipher.java:464|jdk.tls.keyLimits:  entry =  ChaCha20-Poly1305 KeyUpdate 2^37. CHACHA20-POLY1305:KEYUPDATE = 137438953472
javax.net.ssl|DEBUG|01|main|2023-10-17 21:42:59.723 CEST|TrustStoreManager.java:161|Inaccessible trust store: /tmp/not-existing
javax.net.ssl|DEBUG|01|main|2023-10-17 21:42:59.723 CEST|TrustStoreManager.java:112|trustStore is: /opt/openjdk-11/lib/security/cacerts

Das dürfte in vielen Fällen genügen. Ansonsten gibt es noch eine Reihe weiterer Debug-Optionen, die mehr Informationen liefern.

Leave a Reply