Почему java не отправляет сертификат клиента во время рукопожатия SSL?
Я пытаюсь подключиться к безопасному веб-сервису.
Я получил сбой рукопожатия, хотя мое хранилище ключей и хранилище доверенных сертификатов были установлены правильно.
После нескольких дней разочарования, бесконечного поиска в Google и расспросов всех окружающих я обнаружил, что единственной проблемой было то, что java решил не отправлять сертификат клиента на сервер во время рукопожатия.
В частности:
- Сервер запросил сертификат клиента (CN=RootCA), т. Е. "Дай мне сертификат, подписанный корневым центром сертификации"
- Ява заглянула в хранилище ключей и нашла только мой сертификат клиента, подписанный "SubCA", который, в свою очередь, выдан "RootCA". Это не удосужилось заглянуть в склад доверенных... да ладно, я думаю
- К сожалению, когда я попытался добавить сертификат "SubCA" в хранилище ключей, это не помогло вообще. Я проверил, загружены ли сертификаты в хранилище ключей. Они делают, но KeyManager игнорирует все сертификаты, кроме клиентского.
- Все вышеперечисленное приводит к тому, что Java решает, что у нее нет сертификатов, которые удовлетворяют запросу сервера, и ничего не отправляет... Ошибка рукопожатия tadaaa:-(
Мои вопросы:
- Возможно ли, что я добавил сертификат "SubCA" в хранилище ключей таким образом, чтобы "разорвать цепочку сертификатов" или что-то подобное, чтобы KeyManager только загружал сертификат клиента и игнорировал остальные? (Chrome и openssl удается выяснить это, так почему же java не может? - обратите внимание, что сертификат "SubCA" всегда представляется отдельно как доверенный орган, поэтому Chrome, по-видимому, правильно упаковывает его вместе с сертификатом клиента во время рукопожатия)
- Это формальная "проблема конфигурации" на стороне сервера? Сервер является третьей стороной. Я бы ожидал, что сервер запросит сертификат, подписанный органом "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;
Справочные документы
Дело в том, что при использовании клиентского сертификата, подписанного промежуточным сертификатом, необходимо включить промежуточный сертификат в свой список пользователей, чтобы java мог его найти. Либо в одиночку, либо в комплекте с корневым изданием ок.