Почему java не отправляет сертификат клиента во время рукопожатия SSL?

Я пытаюсь подключиться к безопасному веб-сервису.

Я получил сбой рукопожатия, хотя мое хранилище ключей и хранилище доверенных сертификатов были установлены правильно.

После нескольких дней разочарования, бесконечного поиска в Google и расспросов всех окружающих я обнаружил, что единственной проблемой было то, что java решил не отправлять сертификат клиента на сервер во время рукопожатия.

В частности:

  1. Сервер запросил сертификат клиента (CN=RootCA), т. Е. "Дай мне сертификат, подписанный корневым центром сертификации"
  2. Ява заглянула в хранилище ключей и нашла только мой сертификат клиента, подписанный "SubCA", который, в свою очередь, выдан "RootCA". Это не удосужилось заглянуть в склад доверенных... да ладно, я думаю
  3. К сожалению, когда я попытался добавить сертификат "SubCA" в хранилище ключей, это не помогло вообще. Я проверил, загружены ли сертификаты в хранилище ключей. Они делают, но KeyManager игнорирует все сертификаты, кроме клиентского.
  4. Все вышеперечисленное приводит к тому, что Java решает, что у нее нет сертификатов, которые удовлетворяют запросу сервера, и ничего не отправляет... Ошибка рукопожатия tadaaa:-(

Мои вопросы:

  1. Возможно ли, что я добавил сертификат "SubCA" в хранилище ключей таким образом, чтобы "разорвать цепочку сертификатов" или что-то подобное, чтобы KeyManager только загружал сертификат клиента и игнорировал остальные? (Chrome и openssl удается выяснить это, так почему же java не может? - обратите внимание, что сертификат "SubCA" всегда представляется отдельно как доверенный орган, поэтому Chrome, по-видимому, правильно упаковывает его вместе с сертификатом клиента во время рукопожатия)
  2. Это формальная "проблема конфигурации" на стороне сервера? Сервер является третьей стороной. Я бы ожидал, что сервер запросит сертификат, подписанный органом "SubCA", поскольку именно это они нам и предоставили. Я подозреваю, что тот факт, что это работает в Chrome и openssl, объясняется тем, что они "менее строгие", а java просто "идет по книге" и терпит неудачу.

Мне удалось собрать грязный обходной путь для этого, но я не очень доволен этим, поэтому я буду рад, если кто-нибудь сможет уточнить это для меня.

2 ответа

Решение

Возможно, вы импортировали промежуточный сертификат CA в хранилище ключей, не связывая его с записью, в которой у вас есть сертификат клиента и его закрытый ключ. Вы должны быть в состоянии увидеть это, используя keytool -v -list -keystore store.jks, Если вы получаете только один сертификат для каждой записи псевдонима, они не вместе.

Вам нужно будет импортировать ваш сертификат и его цепочку вместе в псевдоним хранилища ключей, который имеет ваш закрытый ключ.

Чтобы узнать, какой псевдоним хранилища ключей имеет закрытый ключ, используйте keytool -list -keystore store.jks (Я предполагаю, что тип магазина JKS здесь). Это скажет вам что-то вроде этого:

Your keystore contains 1 entry

myalias, Feb 15, 2012, PrivateKeyEntry, 
Certificate fingerprint (MD5): xxxxxxxx

Здесь псевдоним myalias, Если вы используете -v в дополнение к этому, вы должны увидеть Alias Name: myalias,

Если у вас его нет отдельно, экспортируйте клиентский сертификат из хранилища ключей:

keytool -exportcert -rfc -file clientcert.pem -keystore store.jks -alias myalias

Это должно дать вам файл PEM.

Использование текстового редактора (или cat), подготовить файл (назовем его bundle.pem) с этим клиентским сертификатом и промежуточным сертификатом CA (и, возможно, самим сертификатом корневого CA, если хотите), так что сертификат клиента находится в начале, а его сертификат эмитента чуть ниже.

Это должно выглядеть так:

-----BEGIN CERTIFICATE-----
MIICajCCAdOgAwIBAgIBAjANBgkqhkiG9w0BAQUFADA7MQswCQYDVQQGEwJVSzEa
....
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICkjCCAfugAwIBAgIJAKm5bDEMxZd7MA0GCSqGSIb3DQEBBQUAMDsxCzAJBgNV
....
-----END CERTIFICATE-----

Теперь импортируйте этот пакет обратно в псевдоним, где ваш закрытый ключ:

keytool -importcert -keystore store.jks -alias myalias -file bundle.pem

В качестве дополнения вы можете использовать %> openssl s_client -connect host.example.com:443 и посмотреть дамп и проверить, что все основные сертификаты действительны для клиента. Вы ищете это в нижней части вывода. Проверьте код возврата: 0 (хорошо)

Если вы добавите -showcerts, он выведет всю информацию о цепочке для ключей, которая была отправлена ​​вместе с сертификатом хоста, который вы загрузили в свою цепочку для ключей.

Большинство решений, которые я видел, основаны на использовании keytool, и ни одно из них не подходит для моего случая.

Вот очень краткое описание: у меня есть PKCS12 (.p12), который отлично работает в Postman с отключенной проверкой сертификата, однако программно я всегда получал ошибку сервера "400 Bad Request" / "Требуемый сертификат SSL не был отправлен".

Причина заключалась в отсутствии SNI расширения TLS (указание имени сервера), и следующее решение.


Добавление расширения / параметра в контекст SSL

После инициализации SSLContext добавьте следующее:

SSLSocketFactory factory = sslContext.getSocketFactory();
    try (SSLSocket socket = (SSLSocket) factory.createSocket(host, port)) {
        SSLParameters sslParameters = socket.getSSLParameters();
        sslParameters.setServerNames(Collections.singletonList(new SNIHostName(hostName)));
        socket.setSSLParameters(sslParameters);
        socket.startHandshake();
    }

Полный класс HTTP-клиента для этого случая (НЕ ДЛЯ ПРОИЗВОДСТВА)

Примечание 1. SSLContextException и KeyStoreFactoryException просто расширяют RuntimeException.

Примечание 2: проверка сертификатов отключена, этот пример предназначен только для использования разработчиками.

Примечание 3: отключение проверки имени хоста в моем случае не требовалось, но я включил его в качестве комментария

import org.apache.http.conn.ssl.NoopHostnameVerifier;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.ssl.SSLContexts;

import javax.net.ssl.*;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.URL;
import java.security.*;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Collections;
import java.util.Objects;

public class SecureClientBuilder {

    private String host;
    private int port;
    private boolean keyStoreProvided;
    private String keyStorePath;
    private String keyStorePassword;

    public SecureClientBuilder withSocket(String host, int port) {
        this.host = host;
        this.port = port;
        return this;
    }

    public SecureClientBuilder withKeystore(String keyStorePath, String keyStorePassword) {
        this.keyStoreProvided = true;
        this.keyStorePath = keyStorePath;
        this.keyStorePassword = keyStorePassword;
        return this;
    }

    public CloseableHttpClient build() {
        SSLContext sslContext = keyStoreProvided
                ? getContextWithCertificate()
                : SSLContexts.createDefault();

        SSLConnectionSocketFactory sslSocketFactory =
                new SSLConnectionSocketFactory(sslContext);

        return HttpClients.custom()
                .setSSLSocketFactory(sslSocketFactory)
                //.setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE)
                .build();
    }

    private SSLContext getContextWithCertificate() {
        try {
            // Generate TLS context with specified KeyStore and
            SSLContext sslContext = SSLContext.getInstance("TLS");
            sslContext.init(getKeyManagerFactory().getKeyManagers(), new TrustManager[]{getTrustManager()}, new SecureRandom());

            SSLSocketFactory factory = sslContext.getSocketFactory();
            try (SSLSocket socket = (SSLSocket) factory.createSocket(host, port)) {
                SSLParameters sslParameters = socket.getSSLParameters();
                sslParameters.setServerNames(Collections.singletonList(new SNIHostName(host)));
                socket.setSSLParameters(sslParameters);
                socket.startHandshake();
            }

            return sslContext;
        } catch (NoSuchAlgorithmException | KeyManagementException | IOException e) {
            throw new SSLContextException("Could not create an SSL context with specified keystore.\nError: " + e.getMessage());
        }
    }

    private KeyManagerFactory getKeyManagerFactory() {
        try (FileInputStream fileInputStream = getResourceFile(keyStorePath)) {
            // Read specified keystore
            KeyStore keyStore = KeyStore.getInstance("PKCS12");
            keyStore.load(fileInputStream, keyStorePassword.toCharArray());

            // Init keystore manager
            KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("SunX509");
            keyManagerFactory.init(keyStore, keyStorePassword.toCharArray());
            return keyManagerFactory;
        } catch (NoSuchAlgorithmException | CertificateException | UnrecoverableKeyException | IOException | KeyStoreException e) {
            throw new KeyStoreFactoryException("Could not read the specified keystore.\nError: " + e.getMessage());
        }
    }

    // Bypasses error: "unable to find valid certification path to requested target"
    private TrustManager getTrustManager() {
        return new X509TrustManager() {
            @Override
            public void checkClientTrusted(java.security.cert.X509Certificate[] arg0, String arg1) {
            }

            @Override
            public void checkServerTrusted(java.security.cert.X509Certificate[] arg0, String arg1) {
            }

            @Override
            public java.security.cert.X509Certificate[] getAcceptedIssuers() {
                return new X509Certificate[0];
            }
        };
    }

    private FileInputStream getResourceFile(String keyStorePath) throws FileNotFoundException {
        URL resourcePath = getClass().getClassLoader().getResource(keyStorePath);
        return new FileInputStream(resourcePath.getFile());
    }

}

Использование вышеуказанного конструктора клиентов

Примечание 1: хранилище ключей (.p12) ищется в папке "ресурсы".

Примечание 2: заголовок "Хост" установлен, чтобы избежать ошибки сервера "400 - неверный запрос".

String hostname = "myHost";
int port = 443;
String keyStoreFile = "keystore.p12";
String keyStorePass = "somepassword";
String endpoint = String.format("https://%s:%d/endpoint", host, port);

CloseableHttpClient apacheClient = new SecureClientBuilder()
        .withSocket(hostname, port)
        .withKeystore(keyStoreFile, keyStorePass)
        .build();

HttpGet get = new HttpGet(endpoint);
get.setHeader("Host", hostname + ":" + port);
CloseableHttpResponse httpResponse = apacheClient.execute(get);

assert httpResponse.getStatusLine().getStatusCode() == 200;

Справочные документы

https://docs.oracle.com/en/java/javase/11/security/java-secure-socket-extension-jsse-reference-guide.html

Дело в том, что при использовании клиентского сертификата, подписанного промежуточным сертификатом, необходимо включить промежуточный сертификат в свой список пользователей, чтобы java мог его найти. Либо в одиночку, либо в комплекте с корневым изданием ок.

Другие вопросы по тегам