HTTPS(含SNI)業務場景「IP直連」方案說明

博客主頁java

1. 背景說明

本文主要介紹HTTPS(含SNI)業務場景下在Android端實現「IP直連」的通用解決方案。若是以OkHttp做爲網絡開發框架,因爲OkHttp提供了自定義DNS服務接口能夠優雅地實現IP直連。其方案相比通用方案更加簡單且通用性更強,推薦您參考HttpDns+OkHttp最佳實踐接入HttpDns。算法

1.1 HTTPS

發送HTTPS請求首先要進行SSL/TLS握手,握手過程大體以下:segmentfault

  1. 客戶端發起握手請求,攜帶隨機數、支持算法列表等參數。
  2. 服務端收到請求,選擇合適的算法,下發公鑰證書和隨機數。
  3. 客戶端對服務端證書進行校驗,併發送隨機數信息,該信息使用公鑰加密。
  4. 服務端經過私鑰獲取隨機數信息。
  5. 雙方根據以上交互的信息生成session ticket,用做該鏈接後續數據傳輸的加密密鑰。

上述過程當中,和HTTPDNS有關的是第3步,客戶端須要驗證服務端下發的證書,驗證過程有如下兩個要點:瀏覽器

  1. 客戶端用本地保存的根證書解開證書鏈,確認服務端下發的證書是由可信任的機構頒發的。
  2. 客戶端須要檢查證書的domain域和擴展域,看是否包含本次請求的host。

若是上述兩點都校驗經過,就證實當前的服務端是可信任的,不然就是不可信任,應當中斷當前鏈接。服務器

當客戶端使用HTTPDNS解析域名時,請求URL中的host會被替換成HTTPDNS解析出來的IP,因此在證書驗證的第2步,會出現domain不匹配的狀況,致使SSL/TLS握手不成功。網絡

1.2 SNI

SNI(Server Name Indication)是爲了解決一個服務器使用多個域名和證書的SSL/TLS擴展。它的工做原理以下:session

  1. 在鏈接到服務器創建SSL連接以前先發送要訪問站點的域名(Hostname)。
  2. 服務器根據這個域名返回一個合適的證書。

目前,大多數操做系統和瀏覽器都已經很好地支持SNI擴展,OpenSSL 0.9.8也已經內置這一功能。併發

上述過程當中,當客戶端使用HTTPDNS解析域名時,請求URL中的host會被替換成HTTPDNS解析出來的IP,致使服務器獲取到的域名爲解析後的IP,沒法找到匹配的證書,只能返回默認的證書或者不返回,因此會出現SSL/TLS握手不成功的錯誤。app

好比當你須要經過HTTPS訪問CDN資源時,CDN的站點每每服務了不少的域名,因此須要經過SNI指定具體的域名證書進行通訊。

2.HTTPS場景(非SNI)解決方案

針對「domain不匹配」問題,能夠採用以下方案解決:hook證書校驗過程當中第2步,將IP直接替換成原來的域名,再執行證書驗證。框架

【注意】基於該方案發起網絡請求,若報出SSL校驗錯誤,Android系統報錯System.err: javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.,請檢查應用場景是否爲SNI(單IP多HTTPS域名)。

2.1 示例

此示例針對HttpURLConnection接口。

try {
    String url = "https://140.205.160.59/?sprefer=sypc00";
    HttpsURLConnection connection = (HttpsURLConnection) new URL(url).openConnection();
    connection.setRequestProperty("Host", "m.taobao.com");
    connection.setHostnameVerifier(new HostnameVerifier() {
/*
 * 關於這個接口的說明,官方有文檔描述:
 * This is an extended verification option that implementers can provide.
 * It is to be used during a handshake if the URL's hostname does not match the
 * peer's identification hostname.
 *
 * 使用HTTPDNS後URL裏設置的hostname不是遠程的主機名(如:m.taobao.com),與證書頒發的域不匹配,
 * Android HttpsURLConnection提供了回調接口讓用戶來處理這種定製化場景。
 * 在確認HTTPDNS返回的源站IP與Session攜帶的IP信息一致後,您能夠在回調方法中將待驗證域名替換爲原來的真實域名進行驗證。
 *
 */
    @Override
    public boolean verify(String hostname, SSLSession session) {
        return HttpsURLConnection.getDefaultHostnameVerifier().verify("m.taobao.com", session);
        return false;
    }
    });
    connection.connect();
} catch (Exception e) {
    e.printStackTrace();
} finally {
}

3. HTTPS(SNI)場景方案

定製SSLSocketFactory,在createSocket時替換爲HTTPDNS的IP,並進行SNI/HostNameVerify配置。

public class HttpDnsTLSSniSocketFactory extends SSLSocketFactory {
    private final String TAG = HttpDnsTLSSniSocketFactory.class.getSimpleName();
    HostnameVerifier hostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier();
    private HttpsURLConnection conn;

    public HttpDnsTLSSniSocketFactory(HttpsURLConnection conn) {
        this.conn = conn;
    }

    @Override
    public Socket createSocket() throws IOException {
        return null;
    }

    @Override
    public Socket createSocket(String host, int port) throws IOException, UnknownHostException {
        return null;
    }

    @Override
    public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException, UnknownHostException {
        return null;
    }

    @Override
    public Socket createSocket(InetAddress host, int port) throws IOException {
        return null;
    }

    @Override
    public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException {
        return null;
    }

    // TLS layer

    @Override
    public String[] getDefaultCipherSuites() {
        return new String[0];
    }

    @Override
    public String[] getSupportedCipherSuites() {
        return new String[0];
    }

    @Override
    public Socket createSocket(Socket plainSocket, String host, int port, boolean autoClose) throws IOException {
        String peerHost = this.conn.getRequestProperty("Host");
        if (peerHost == null)
            peerHost = host;
        Log.i(TAG, "customized createSocket. host: " + peerHost);
        InetAddress address = plainSocket.getInetAddress();
        if (autoClose) {
            // we don't need the plainSocket
            plainSocket.close();
        }
        // create and connect SSL socket, but don't do hostname/certificate verification yet
        SSLCertificateSocketFactory sslSocketFactory = (SSLCertificateSocketFactory) SSLCertificateSocketFactory.getDefault(0);
        SSLSocket ssl = (SSLSocket) sslSocketFactory.createSocket(address, port);

        // enable TLSv1.1/1.2 if available
        ssl.setEnabledProtocols(ssl.getSupportedProtocols());

        // set up SNI before the handshake
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
            Log.i(TAG, "Setting SNI hostname");
            sslSocketFactory.setHostname(ssl, peerHost);
        } else {
            Log.d(TAG, "No documented SNI support on Android <4.2, trying with reflection");
            try {
                java.lang.reflect.Method setHostnameMethod = ssl.getClass().getMethod("setHostname", String.class);
                setHostnameMethod.invoke(ssl, peerHost);
            } catch (Exception e) {
                Log.w(TAG, "SNI not useable", e);
            }
        }

        // verify hostname and certificate
        SSLSession session = ssl.getSession();

        if (!hostnameVerifier.verify(peerHost, session))
            throw new SSLPeerUnverifiedException("Cannot verify hostname: " + peerHost);

        Log.i(TAG, "Established " + session.getProtocol() + " connection with " + session.getPeerHost() +
                " using " + session.getCipherSuite());

        return ssl;
    }
}

對於須要設置SNI的站點,一般須要重定向請求,下面給出了重定向請求的處理方法。

public void recursiveRequest(String path, String reffer) {
    URL url = null;
    try {
        url = new URL(path);
        conn = (HttpsURLConnection) url.openConnection();
        String ip = httpdns.getIpByHostAsync(url.getHost());
        if (ip != null) {
            // 經過HTTPDNS獲取IP成功,進行URL替換和HOST頭設置
            Log.d(TAG, "Get IP: " + ip + " for host: " + url.getHost() + " from HTTPDNS successfully!");
            String newUrl = path.replaceFirst(url.getHost(), ip);
            conn = (HttpsURLConnection) new URL(newUrl).openConnection();
            // 設置HTTP請求頭Host域
            conn.setRequestProperty("Host", url.getHost());
        }
        conn.setConnectTimeout(30000);
        conn.setReadTimeout(30000);
        conn.setInstanceFollowRedirects(false);
        HttpDnsTLSSniSocketFactory sslSocketFactory = new HttpDnsTLSSniSocketFactory(conn);
        conn.setSSLSocketFactory(sslSocketFactory);
        conn.setHostnameVerifier(new HostnameVerifier() {
            /*
             * 關於這個接口的說明,官方有文檔描述:
             * This is an extended verification option that implementers can provide.
             * It is to be used during a handshake if the URL's hostname does not match the
             * peer's identification hostname.
             *
             * 使用HTTPDNS後URL裏設置的hostname不是遠程的主機名(如:m.taobao.com),與證書頒發的域不匹配,
             * Android HttpsURLConnection提供了回調接口讓用戶來處理這種定製化場景。
             * 在確認HTTPDNS返回的源站IP與Session攜帶的IP信息一致後,您能夠在回調方法中將待驗證域名替換爲原來的真實域名進行驗證。
             *
             */
            @Override
            public boolean verify(String hostname, SSLSession session) {
                String host = conn.getRequestProperty("Host");
                if (null == host) {
                    host = conn.getURL().getHost();
                }
                return HttpsURLConnection.getDefaultHostnameVerifier().verify(host, session);
            }
        });
        int code = conn.getResponseCode();// Network block
        if (needRedirect(code)) {
            //臨時重定向和永久重定向location的大小寫有區分
            String location = conn.getHeaderField("Location");
            if (location == null) {
                location = conn.getHeaderField("location");
            }
            if (!(location.startsWith("http://") || location
                    .startsWith("https://"))) {
                //某些時候會省略host,只返回後面的path,因此須要補全url
                URL originalUrl = new URL(path);
                location = originalUrl.getProtocol() + "://"
                        + originalUrl.getHost() + location;
            }
            recursiveRequest(location, path);
        } else {
            // redirect finish.
            DataInputStream dis = new DataInputStream(conn.getInputStream());
            int len;
            byte[] buff = new byte[4096];
            StringBuilder response = new StringBuilder();
            while ((len = dis.read(buff)) != -1) {
                response.append(new String(buff, 0, len));
            }
            Log.d(TAG, "Response: " + response.toString());
        }
    } catch (MalformedURLException e) {
        Log.w(TAG, "recursiveRequest MalformedURLException");
    } catch (IOException e) {
        Log.w(TAG, "recursiveRequest IOException");
    } catch (Exception e) {
        Log.w(TAG, "unknow exception");
    } finally {
        if (conn != null) {
            conn.disconnect();
        }
    }
}
private boolean needRedirect(int code) {
    return code >= 300 && code < 400;
}

若是個人文章對您有幫助,不妨點個贊鼓勵一下(^_^)

相關文章
相關標籤/搜索