7
7

More than 3 years have passed since last update.

JavaでSNIの有効・無効を通信ごとに切り替える

Last updated at Posted at 2017-11-16

課題

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メソッドに空のリストを渡してあげることで、通信単位で個別に無効化できた。

Main.java
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のアップデート時にハマったのが、この記事を書くきっかけとなった。

7
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
7