Konfiguracja bezpiecznego połączenia w Javie

Autor
Damian
Terlecki
22 minuty
Java

Standardowy pakiet Javy, który zapewnia warstwę abstrakcji umożliwiającą bezpieczną komunikację sieciową (zarządzanie certyfikatami, handshaking i weryfikacja) to javax.net.ssl. Prawdopodobnie najpopularniejszym protokołem, z którym programiści mają do czynienia w tym obszarze, jest HTTPS. HTTPS jest bezpieczną wersją protokołu HTTP (RFC 2616). Może być on zaimplementowany poprzez wykorzystanie protokołu niższej warstwy modelu sieci, np. SSL bądź bezpieczniejszą i zaktualizowaną odpowiednikiem – protokołem TLS.

ProtokółPublikacjaWsparcie stron internetowychBezpieczeństwo
SSL 1.0 Nieopublikowany
SSL 2.0 1995 1.6% Niebezpieczny
SSL 3.0 1996 6.7% Niebezpieczny
TLS 1.0 1999 65.0% Zależy szyfru i ograniczeń po stronie klienta
TLS 1.1 2006 75.1% Zależy szyfru i ograniczeń po stronie klienta
TLS 1.2 2008 96.0% Zależy szyfru i ograniczeń po stronie klienta
TLS 1.3 2018 18.4% Bezpieczny

Źródła: https://en.wikipedia.org/wiki/Transport_Layer_Security, https://www.ssllabs.com/ssl-pulse/

Obsługa różnych wersji protokołów i szyfrów w Javie jest realizowana w formie architektury bezpieczeństwa typu pluggable za pośrednictwem tzw. dostawców zabezpieczeń. Domyślnie co najmniej jeden dostawca zabezpieczeń (provider) jest dystrybuowany wraz z JRE/JDK, a w razie potrzeby możemy dodać własnego dostawcę.

Na przykład w chwili pisania tego artykułu Oracle JRE8/JDK8 nie zapewniają obsługi protokołu TLS 1.3, chociaż jest to planowane na 2020-07-14. Tymczasem możemy cieszyć się z TLS 1.3 w Javie 11 i na JVM/JDK Javy 8 od Azul Systems (Zing/Zulu).

Konfiguracja bezpiecznego połączenia

Wcześniej wspomniany security provider dostarczany jest do klasy SSLContext, którego następnie używamy do skonfigurowania połączenia. Domyślne obsługiwane protokoły zawarte są w parametrach kontekstu SSLContext.getDefault().getSupportedSSLParameters().getProtocols(). Aby ograniczyć listę tylko do wybranych protokołów, możemy użyć metody setEnabledProtocols(String[] protocols) klasy SSLContext.

Przed tym rzućmy najpierw okiem na elementy potrzebne do inicjalizacji kontekstu:

KlasaOpisPrzykład użycia
SSLContext Warstwa abstrakcji pozwalająca na konfigurację połączenia SSL/TLS przy użyciu certyfikatów zawartych w zarządzanych trust/key store'ach.
            SSLContext context = SSLContext.getInstance("TLSv1.2");
context.init(keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), null);
         
TrustStore Przechowuje zaufane certyfikaty z punktu widzenia klienta.
KeyStore Przechowuje certyfikat naszej tożsamości.
            KeyStore ks = KeyStore.getInstance("JKS");
char[] password = "changeit".toCharArray();
try (FileInputStream fis = FileInputStream("path/to/keystore")) {
    ks.load(fis, password);
}
         
TrustManagerFactory
/
KeyManagerFactory
Fabryki do zarządzania magazynami kluczy, które mogą być dostarczane przez środowisko bądź zainicjalizowane z konkretnego keystore'a. Możemy również pominąć inicjalizację za pomocą fabryki, dostarczając własną implementację menadżera.
            KeyManagerFactory kmf = KeyManagerFactory.getInstance(ksAlgorithm);
kmf.init(ks, password);
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init((KeyStore) null); // Default keystore will be used
         
KeyManager Udostępnia klientowi łańcuch certyfikatów wraz z kluczem publicznym oraz przechowuje klucz prywatny do odszyfrowywania danych zaszyfrowanych przez klucz publiczny. Wiemy, że hasła są powiązane z magazynami kluczy, ale klucze prywatne również mogą mieć hasła. Ponieważ interfejs KeyManagera nie udostępnia możliwości podania hasła dla klucza prywatnego, gdy używany jest standardowy algorytm "SunX509" przy tworzeniu samej fabryki, zakłada się, że jest ono takie samo jak hasło do keystore'a.
Jeśli jednak użyjemy algorytmu „NewSunX509”, możemy rozwiązać ten problem – szczegółowiej zostało to wyjaśniene przz Willa Argenta.
TrustManager Decyduje czy odrzucić połączenie na podstawie danych uwierzytelniających podanych przez drugą stronę.
HostnameVerifier Podczas połączenia SSL/TLS w celu zapobiegania atakom MITM (Man In The Middle) zaleca się sprawdzenie, czy docelowa nazwa hosta jest taka sama jak nazwa podana w certyfikacie. Trzy popularne implementacje można znaleźć w bibliotece Apache HttpComponents:
  • org.apache.http.conn.ssl.DefaultHostnameVerifier – weryfikuje nazwę hosta (IPv4/IPv6/DNS) na podstawie RFC 2818 w sposób rygorystyczny (tylko pojedynczy wildcard w nazwie domeny jest dozwolony) poprzez porównanie docelowej nazwy hosta i wartości DNS Name z certyfikatu z pola Subject Alternative Name (subjectAltName);
  • org.apache.http.conn.ssl.BrowserCompatHostnameVerifier – podobnie do DefaultHostnameVerifier, ale bez obostrzeń związanych z wildcardem, oznaczony jako przestarzały;
  • org.apache.http.conn.ssl.NoopHostnameVerifier – podczas weryfikacji zwraca true, tj. weryfikacja nie jest przeprowadzana – nie należy używać tej implementacji, chyba że zawęzimy zakres dopuszczalnych certyfikatów do tego, który przedstawia serwer (RFC 6125).
Jeśli te trzy rozwiązania nie pasują do Twojego przypadku, możesz dostarczyć własną implementację w oparciu o jakieś informacje zewnętrzne. Szczegółowiej weryfikację opisuje Will Argent w kolejnym artykule.

Większość klientów HTTP obsługuje konfigurację połączenia za pomocą klasy SSLContext. Zasadniczo domyślna konfiguracja zapewniana przez JDK/JRE często wystarcza przy inicjalizacji bezpiecznego połączenia jako klient. O ile oczywiście serwer nie wymaga również od nas ważnego certyfikatu. W takim przypadku będziemy musieli przekazać naszą tożsamość za pomocą KeyManagera.

Kilka przykładów ostatecznego ogniwa między konfiguracją połączenia a klasami klientów HTTP zamieściłem poniżej:

// javax.net.ssl
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
connection.setSSLSocketFactory(sslContext.getSocketFactory());
connection.setHostnameVerifier(hostnameVerifier);

// org.apache.httpcomponents:httpclient:4.5
CloseableHttpClient httpClient = HttpClientBuilder.create()
    .setSSLContext(sslContext)
    .setHostnameVerifier(hostnameVerifier)
    .build();

// com.squareup.okhttp3:okhttp:4.x
OkHttpClient okHttpClient = OkHttpClient.Builder()
    .sslSocketFactory(sslContext.getSocketFactory(), trustManager)
    .hostnameVerifier(hostnameVerifier)
    .build()

// org.glassfish.jersey.core:jersey-client:2.x
Client jerseyClient = ClientBuilder.newBuilder()
    .sslContext(sslContext)
    .hostnameVerifier(hostnameVerifier)
    .build();

Na potrzeby zarządzania keystore'ami, tworzenia CSR (Certificate Signing Request – żądania podpisania przez urząd certyfikacji) używa się programu wiersza poleceń keytool zawartego w katalogu bin JRE/JDK. Listę przydatnych poleceń można podejrzeć na stronie SSL Shopper.

W razie wątpliwości, dlaczego standardowa konfiguracja nie działa, zawsze warto sprawdzić ważność certyfikatu witryny, z którą się łączymy, nazwę domeny i łańcuch poświadczeń. No chyba że certyfikat jest self-signed i zaimportowaliśmy go do trust store'a (również to powinniśmy sprawdzić).

Wyświetlenie certyfikatu witryny za pomocą przeglądarki (ikona kłódki) Weryfikacja nazwy DNS certyfikatu Sprawdzanie ścieżki certyfikacji

Często jednak sprawdzanie certyfikatu na serwerach za pomocą przeglądarki nie jest wykonalnym scenariuszem, gdyż zazwyczaj nie udostępniają one powłoki graficznej. W takim przypadku możemy skorzystać z narzędzi wiersza polecenia, takich jak curl lub openssl, w celu wyodrębnienia certyfikatu.