課題
https通信を行う際、サーバー側の設定によってSNI(Wikipedia参照)を有効にすべき場合と、逆にSNIを無効すべき場合とがある。
これが原因で接続に失敗した場合は以下の例外が発生する。
javax.net.ssl.SSLHandshakeException: Received fatal alert: handshake_failure
Java7以降ではデフォルトで有効に設定されていて、JVMの起動オプションに「-Djsse.enableSNIExtension=false」を指定することで無効化できる。(参考記事)
ただし、この方法ではシステム全体を有効・無効のどちらかに設定するため、接続先によって有効・無効を切り替えたい場合にうまくいかない。
なお、1.8.0_141未満のバージョンではconnection.setHostnameVerifierを呼び出すことで副次的にSNIを無効化できたが、1.8.0_141でこの挙動は修正され常に起動オプションに従うようになった。
解決策
起動オプションではSNIを有効にした状態で、SocketFactoryにてSocketを作成する際にsetServerNamesメソッドに空のリストを渡してあげることで、通信単位で個別に無効化できた。
import java.io.*;
import java.net.*;
import java.security.GeneralSecurityException;
import java.util.ArrayList;
import javax.net.ssl.*;
import sun.security.ssl.SSLSocketImpl;
public class Main {
public static void connect(String url, boolean disableSni, boolean disableHostnameCheck) throws IOException, GeneralSecurityException {
HttpsURLConnection connection;
connection = (HttpsURLConnection)(new URL(url)).openConnection();
connection.setRequestMethod("GET");
// ホスト名チェック無効化
if (disableHostnameCheck) {
connection.setHostnameVerifier(new HostnameVerifier() {
public boolean verify(String hostname, SSLSession session) {
return true;
}
});
}
// 証明書チェック無効化
SSLContext sslContext = SSLContext.getInstance("SSL");
sslContext.init(null, new X509TrustManager[] {new RelaxedX509TrustManager()}, new java.security.SecureRandom());
SSLSocketFactory socketFactory = sslContext.getSocketFactory();
// SNI無効化
if (disableSni) {
socketFactory = new SniDisabledSSLSocketFactory(socketFactory);
}
// 設定をconnectionに反映
connection.setSSLSocketFactory(socketFactory);
// 結果を取得して出力
connection.connect();
int responseCode = connection.getResponseCode();
System.out.println("ResponseCode : " + responseCode);
InputStream input = null;
try {
input = connection.getInputStream();
} catch (IOException e) {
input = connection.getErrorStream();
}
BufferedReader reader = new BufferedReader(new InputStreamReader(input));
while (true) {
String line = reader.readLine();
if (line == null) {
break;
}
System.out.println(line);
}
}
public static void main(String[] args) throws Exception {
connect("https://www.example.com/", false, true);
connect("https://www.example.com/", true, true);
connect("https://www.example.com/", false, false);
connect("https://www.example.com/", true, false); // このパターンだけ未テスト
}
}
/**
* jsse.enableSNIExtensionの設定に関わらずSNIを無効にするSSLSocketFactory
*/
class SniDisabledSSLSocketFactory extends SSLSocketFactory {
private SSLSocketFactory baseSocketFactory;
public SniDisabledSSLSocketFactory(SSLSocketFactory baseSocketFactory) {
this.baseSocketFactory = baseSocketFactory;
}
private Socket setSni(Socket socket) {
SSLParameters params = ((SSLSocketImpl)socket).getSSLParameters();
params.setServerNames(new ArrayList<SNIServerName>()); // ホスト名を空にすることでSNIを無効にする
((SSLSocketImpl)socket).setSSLParameters(params);
return socket;
}
@Override
public String[] getDefaultCipherSuites() {
return baseSocketFactory.getDefaultCipherSuites();
}
@Override
public String[] getSupportedCipherSuites() {
return baseSocketFactory.getSupportedCipherSuites();
}
@Override
public Socket createSocket(Socket paramSocket, String paramString, int paramInt, boolean paramBoolean) throws IOException {
return setSni(baseSocketFactory.createSocket(paramSocket, paramString, paramInt, paramBoolean));
}
@Override
public Socket createSocket(String paramString, int paramInt) throws IOException, UnknownHostException {
return setSni(baseSocketFactory.createSocket(paramString, paramInt));
}
@Override
public Socket createSocket(String paramString, int paramInt1, InetAddress paramInetAddress, int paramInt2) throws IOException, UnknownHostException {
return setSni(baseSocketFactory.createSocket(paramString, paramInt1, paramInetAddress, paramInt2));
}
@Override
public Socket createSocket(InetAddress paramInetAddress, int paramInt) throws IOException {
return setSni(baseSocketFactory.createSocket(paramInetAddress, paramInt));
}
@Override
public Socket createSocket(InetAddress paramInetAddress1, int paramInt1, InetAddress paramInetAddress2, int paramInt2) throws IOException {
return setSni(baseSocketFactory.createSocket(paramInetAddress1, paramInt1, paramInetAddress2, paramInt2));
}
}
/**
* 全てを許可するTrustManager
*/
class RelaxedX509TrustManager implements javax.net.ssl.X509TrustManager {
public boolean isClientTrusted( java.security.cert.X509Certificate[] chain ){
return true;
}
public boolean isServerTrusted( java.security.cert.X509Certificate[] chain ){
return true;
}
public java.security.cert.X509Certificate[] getAcceptedIssuers() {
return null;
}
public void checkClientTrusted(java.security.cert.X509Certificate[] chain, String authType) {}
public void checkServerTrusted(java.security.cert.X509Certificate[] chain, String authType) {}
}
動作確認
実際にSNI有効のみを受け付けるサーバー・無効のみを受け付けるサーバーのそれぞれに接続して、目的通り動作することを確認済み。
ただし、「ホスト名チェック有効で接続できる」かつ「SNI無効のみを受け付ける」ようなサーバーを用意できなかったので、そのパターンはテストできていない。そのパターンであっても、パケットキャプチャでSNIの情報が送付されていないことは確認している。
証明書チェック無効化のロジックを削除して、connection.getSSLSocketFactory()で取得したSocketFactoryをベースにSniDisabledSSLSocketFactory を作成した場合は、なぜかうまくいかなかった(=常にSNIが有効化されてしまった)。
SSLContext.getInstance("SSL")で生成したSSLContextを使った上で、適切に証明書チェックを行いつつSNIを無効化できるかどうかは未確認。
余談
SNI有効化の効果は、SSL/TSLのClient Helloパケットでホスト名を送付することだけだと思うが、なぜ「SNI有効にするとエラーになるサーバー」があるのかよくわからない。サーバー側のミドルウェアでSNIが有効化されているが設定が不完全、とかだろうか?
冒頭に書いた通りJava 7以降ではSNIがデフォルトで有効化されているが、1.8.0_141未満のバージョンではsetHostnameVerifier(HostnameVerifier v)を呼び出して独自のVerifierを登録している場合にはSNIが無効化されていた。
それが1.8.0_141以降は有効化されるようになった。(Release Note参照)
これが原因でJavaのアップデート時にハマったのが、この記事を書くきっかけとなった。