Https協議與HttpClient的實現

1、背景

  HTTP是一個傳輸內容有可讀性的公開協議,客戶端與服務器端的數據徹底經過明文傳輸。在這個背景之下,整個依賴於Http協議的互聯網數據都是透明的,這帶來了很大的數據安全隱患。想要解決這個問題有兩個思路:html

  1. C/S端各自負責,即客戶端與服務端使用協商好的加密內容在Http上通訊
  2. C/S端不負責加解密,加解密交給通訊協議自己解決

  第一種在現實中的應用範圍其實比想象中的要普遍一些。雙方線下交換密鑰,客戶端在發送的數據採用的已是密文了,這個密文經過透明的Http協議在互聯網上傳輸。服務端在接收到請求後,按照約定的方式解密得到明文。這種內容就算被劫持了也沒關係,由於第三方不知道他們的加解密方法。然而這種作法太特殊了,客戶端與服務端都須要關心這個加解密特殊邏輯。算法

  第二種C/S端能夠不關心上面的特殊邏輯,他們認爲發送與接收的都是明文,由於加解密這一部分已經被協議自己處理掉了。安全

  從結果上看這兩種方案彷佛沒有什麼區別,可是從軟件工程師的角度看區別很是巨大。由於第一種須要業務系統本身開發響應的加解密功能,而且線下要交互密鑰,第二種沒有開發量。服務器

  HTTPS是當前最流行的HTTP的安全形式,由NetScape公司獨創。在HTTPS中,URL都是以https://開頭,而不是http://。使用了HTTPS時,全部的HTTP的請求與響應在發送到網絡上以前都進行了加密,這是經過在SSL層實現的。網絡

  

2、加密方法

  經過SSL層對明文數據進行加密,而後放到互聯網上傳輸,這解決了HTTP協議本來的數據安全性問題。通常來講,對數據加密的方法分爲對稱加密與非對稱加密。curl

2.1 對稱加密

  對稱加密是指加密與解密使用一樣的密鑰,常見的算法有DES與AES等,算法時間與密鑰長度相關。socket

  對稱密鑰最大的缺點是須要維護大量的對稱密鑰,而且須要線下交換。加入一個網絡中有n個實體,則須要n(n-1)個密鑰。tcp

2.2 非對稱加密

  非對稱加密是指基於公私鑰(public/private key)的加密方法,常見算法有RSA,通常而言加密速度慢於對稱加密。ide

  對稱加密比非對稱加密多了一個步驟,即要得到服務端公鑰,而不是各自維護的密鑰。oop

  整個加密算法創建在必定的數論基礎上運算,達到的效果是,加密結果不可逆。即只有經過私鑰(private key)才能解密獲得經由公鑰(public key)加密的密文。

  在這種算法下,整個網絡中的密鑰數量大大下降,每一個人只須要維護一對公司鑰便可。即n個實體的網絡中,密鑰個數是2n。

  其缺點是運行速度慢。

2.3 混合加密

  周星馳電影《食神》中有一個場景,黑社會火併,爭論撒尿蝦與牛丸的底盤劃分問題。食神說:「真是麻煩,摻在一塊兒作成撒尿牛丸那,笨蛋!」

  對稱加密的優勢是速度快,缺點是須要交換密鑰。非對稱加密的優勢是不須要交互密鑰,缺點是速度慢。乾脆摻在一塊兒用好了。

  混合加密正是HTTPS協議使用的加密方式。先經過非對稱加密交換對稱密鑰,後經過對稱密鑰進行數據傳輸。

  因爲數據傳輸的量遠遠大於創建鏈接初期交換密鑰時使用非對稱加密的數據量,因此非對稱加密帶來的性能影響基本能夠忽略,同時又提升了效率。

3、HTTPS握手

  能夠看到,在原HTTP協議的基礎上,HTTPS加入了安全層處理:

  1. 客戶端與服務端交換證書並驗證身份,現實中服務端不多驗證客戶端的證書
  2. 協商加密協議的版本與算法,這裏可能出現版本不匹配致使失敗
  3. 協商對稱密鑰,這個過程使用非對稱加密進行
  4. 將HTTP發送的明文使用3中的密鑰,2中的加密算法加密獲得密文
  5. TCP層正常傳輸,對HTTPS無感知

4、HttpClient對HTTPS協議的支持

4.1 得到SSL鏈接工廠以及域名校驗器

  做爲一名軟件工程師,咱們關心的是「HTTPS協議」在代碼上是怎麼實現的呢?探索HttpClient源碼的奧祕,一切都要從HttpClientBuilder開始。

public CloseableHttpClient build() {
        //省略部分代碼
        HttpClientConnectionManager connManagerCopy = this.connManager;
        //若是指定了鏈接池管理器則使用指定的,不然新建一個默認的
        if (connManagerCopy == null) {
            LayeredConnectionSocketFactory sslSocketFactoryCopy = this.sslSocketFactory;
            if (sslSocketFactoryCopy == null) {
                //若是開啓了使用環境變量,https版本與密碼控件從環境變量中讀取
                final String[] supportedProtocols = systemProperties ? split(
                        System.getProperty("https.protocols")) : null;
                final String[] supportedCipherSuites = systemProperties ? split(
                        System.getProperty("https.cipherSuites")) : null;
                //若是沒有指定,使用默認的域名驗證器,會根據ssl會話中服務端返回的證書來驗證與域名是否匹配
                HostnameVerifier hostnameVerifierCopy = this.hostnameVerifier;
                if (hostnameVerifierCopy == null) {
                    hostnameVerifierCopy = new DefaultHostnameVerifier(publicSuffixMatcherCopy);
                }
                //若是制定了SslContext則生成定製的SSL鏈接工廠,不然使用默認的鏈接工廠
                if (sslContext != null) {
                    sslSocketFactoryCopy = new SSLConnectionSocketFactory(
                            sslContext, supportedProtocols, supportedCipherSuites, hostnameVerifierCopy);
                } else {
                    if (systemProperties) {
                        sslSocketFactoryCopy = new SSLConnectionSocketFactory(
                                (SSLSocketFactory) SSLSocketFactory.getDefault(),
                                supportedProtocols, supportedCipherSuites, hostnameVerifierCopy);
                    } else {
                        sslSocketFactoryCopy = new SSLConnectionSocketFactory(
                                SSLContexts.createDefault(),
                                hostnameVerifierCopy);
                    }
                }
            }
            //將Ssl鏈接工廠註冊到鏈接池管理器中,當須要產生Https鏈接的時候,會根據上面的SSL鏈接工廠生產SSL鏈接
            @SuppressWarnings("resource")
            final PoolingHttpClientConnectionManager poolingmgr = new PoolingHttpClientConnectionManager(
                    RegistryBuilder.<ConnectionSocketFactory>create()
                        .register("http", PlainConnectionSocketFactory.getSocketFactory())
                        .register("https", sslSocketFactoryCopy)
                        .build(),
                    null,
                    null,
                    dnsResolver,
                    connTimeToLive,
                    connTimeToLiveTimeUnit != null ? connTimeToLiveTimeUnit : TimeUnit.MILLISECONDS);
            //省略部分代碼
    }
}

  上面的代碼將一個Ssl鏈接工廠SSLConnectionSocketFactory建立,並註冊到了鏈接池管理器中,供以後生產Ssl鏈接使用。鏈接池的問題參考:http://www.cnblogs.com/kingszelda/p/8988505.html

  這裏在配置SSLConnectionSocketFactory時用到了幾個關鍵的組件,域名驗證器HostnameVerifier以及上下文SSLContext。

  其中HostnameVerifier用來驗證服務端證書與域名是否匹配,有多種實現,DefaultHostnameVerifier採用的是默認的校驗規則,替代了以前版本中的BrowserCompatHostnameVerifier與StrictHostnameVerifier。NoopHostnameVerifier替代了AllowAllHostnameVerifier,採用的是不驗證域名的策略。

  注意,這裏有一些區別,BrowserCompatHostnameVerifier能夠匹配多級子域名,"*.foo.com"能夠匹配"a.b.foo.com"。StrictHostnameVerifier不能匹配多級子域名,只能到"a.foo.com"。

  而4.4以後的HttpClient使用了新的DefaultHostnameVerifier替換了上面的兩種策略,只保留了一種嚴格策略及StrictHostnameVerifier。由於嚴格策略是IE6與JDK自己的策略,非嚴格策略是curl與firefox的策略。即默認的HttpClient實現是不支持多級子域名匹配策略的。

  SSLContext存放的是和密鑰有關的關鍵信息,這部分與業務直接相關,很是重要,這個放在後面單獨分析。

4.2 如何得到SSL鏈接

  如何從鏈接池中得到一個鏈接,這個過程以前的文章中有分析過,這裏不作分析,參考鏈接:http://www.cnblogs.com/kingszelda/p/8988505.html。

  在從鏈接池中得到一個鏈接後,若是這個鏈接不處於establish狀態,就須要先創建鏈接。

  DefaultHttpClientConnectionOperator部分的代碼爲:

    public void connect(
            final ManagedHttpClientConnection conn,
            final HttpHost host,
            final InetSocketAddress localAddress,
            final int connectTimeout,
            final SocketConfig socketConfig,
            final HttpContext context) throws IOException {
        //以前在HttpClientBuilder中register了http與https不一樣的鏈接池實現,這裏lookup得到Https的實現,即SSLConnectionSocketFactory    
        final Lookup<ConnectionSocketFactory> registry = getSocketFactoryRegistry(context);
        final ConnectionSocketFactory sf = registry.lookup(host.getSchemeName());
        if (sf == null) {
            throw new UnsupportedSchemeException(host.getSchemeName() +
                    " protocol is not supported");
        }
        //若是是ip形式的地址能夠直接使用,不然使用dns解析器解析獲得域名對應的ip
        final InetAddress[] addresses = host.getAddress() != null ?
                new InetAddress[] { host.getAddress() } : this.dnsResolver.resolve(host.getHostName());
        final int port = this.schemePortResolver.resolve(host);
        //一個域名可能對應多個Ip,按照順序嘗試鏈接
        for (int i = 0; i < addresses.length; i++) {
            final InetAddress address = addresses[i];
            final boolean last = i == addresses.length - 1;
            //這裏只是生成一個socket,還並無鏈接
            Socket sock = sf.createSocket(context);
            //設置一些tcp層的參數
            sock.setSoTimeout(socketConfig.getSoTimeout());
            sock.setReuseAddress(socketConfig.isSoReuseAddress());
            sock.setTcpNoDelay(socketConfig.isTcpNoDelay());
            sock.setKeepAlive(socketConfig.isSoKeepAlive());
            if (socketConfig.getRcvBufSize() > 0) {
                sock.setReceiveBufferSize(socketConfig.getRcvBufSize());
            }
            if (socketConfig.getSndBufSize() > 0) {
                sock.setSendBufferSize(socketConfig.getSndBufSize());
            }

            final int linger = socketConfig.getSoLinger();
            if (linger >= 0) {
                sock.setSoLinger(true, linger);
            }
            conn.bind(sock);

            final InetSocketAddress remoteAddress = new InetSocketAddress(address, port);
            if (this.log.isDebugEnabled()) {
                this.log.debug("Connecting to " + remoteAddress);
            }
            try {
                //經過SSLConnectionSocketFactory創建鏈接並綁定到conn上
                sock = sf.connectSocket(
                        connectTimeout, sock, host, remoteAddress, localAddress, context);
                conn.bind(sock);
                if (this.log.isDebugEnabled()) {
                    this.log.debug("Connection established " + conn);
                }
                return;
            } 
            //省略一些代碼
        }
    }

  在上面的代碼中,咱們看到了是創建SSL鏈接以前的準備工做,這是通用流程,普通HTTP鏈接也同樣。SSL鏈接的特殊流程體如今哪裏呢?

  SSLConnectionSocketFactory部分源碼以下:

    @Override
    public Socket connectSocket(
            final int connectTimeout,
            final Socket socket,
            final HttpHost host,
            final InetSocketAddress remoteAddress,
            final InetSocketAddress localAddress,
            final HttpContext context) throws IOException {
        Args.notNull(host, "HTTP host");
        Args.notNull(remoteAddress, "Remote address");
        final Socket sock = socket != null ? socket : createSocket(context);
        if (localAddress != null) {
            sock.bind(localAddress);
        }
        try {
            if (connectTimeout > 0 && sock.getSoTimeout() == 0) {
                sock.setSoTimeout(connectTimeout);
            }
            if (this.log.isDebugEnabled()) {
                this.log.debug("Connecting socket to " + remoteAddress + " with timeout " + connectTimeout);
            }
            //創建鏈接
            sock.connect(remoteAddress, connectTimeout);
        } catch (final IOException ex) {
            try {
                sock.close();
            } catch (final IOException ignore) {
            }
            throw ex;
        }
        // 若是當前是SslSocket則進行SSL握手與域名校驗
        if (sock instanceof SSLSocket) {
            final SSLSocket sslsock = (SSLSocket) sock;
            this.log.debug("Starting handshake");
            sslsock.startHandshake();
            verifyHostname(sslsock, host.getHostName());
            return sock;
        } else {
            //若是不是SslSocket則將其包裝爲SslSocket
            return createLayeredSocket(sock, host.getHostName(), remoteAddress.getPort(), context);
        }
    }

    @Override
    public Socket createLayeredSocket(
            final Socket socket,
            final String target,
            final int port,
            final HttpContext context) throws IOException {
            //將普通socket包裝爲SslSocket,socketfactory是根據HttpClientBuilder中的SSLContext生成的,其中包含密鑰信息
        final SSLSocket sslsock = (SSLSocket) this.socketfactory.createSocket(
                socket,
                target,
                port,
                true);
        //若是制定了SSL層協議版本與加密算法,則使用指定的,不然使用默認的
        if (supportedProtocols != null) {
            sslsock.setEnabledProtocols(supportedProtocols);
        } else {
            // If supported protocols are not explicitly set, remove all SSL protocol versions
            final String[] allProtocols = sslsock.getEnabledProtocols();
            final List<String> enabledProtocols = new ArrayList<String>(allProtocols.length);
            for (final String protocol: allProtocols) {
                if (!protocol.startsWith("SSL")) {
                    enabledProtocols.add(protocol);
                }
            }
            if (!enabledProtocols.isEmpty()) {
                sslsock.setEnabledProtocols(enabledProtocols.toArray(new String[enabledProtocols.size()]));
            }
        }
        if (supportedCipherSuites != null) {
            sslsock.setEnabledCipherSuites(supportedCipherSuites);
        }

        if (this.log.isDebugEnabled()) {
            this.log.debug("Enabled protocols: " + Arrays.asList(sslsock.getEnabledProtocols()));
            this.log.debug("Enabled cipher suites:" + Arrays.asList(sslsock.getEnabledCipherSuites()));
        }

        prepareSocket(sslsock);
        this.log.debug("Starting handshake");
        //Ssl鏈接握手
        sslsock.startHandshake();
        //握手成功後校驗返回的證書與域名是否一致
        verifyHostname(sslsock, target);
        return sslsock;
    }

  能夠看到,對於一個SSL通訊而言。首先是創建普通socket鏈接,而後進行ssl握手,以後驗證證書與域名一致性。以後的操做就是經過SSLSocketImpl進行通訊,協議細節在SSLSocketImpl類中體現,但這部分代碼jdk並無開源,感興趣的能夠下載相應的openJdk源碼繼續分析。

5、本文總結

  1. https協議是http的安全版本,作到了傳輸層數據的安全,但對服務器cpu有額外消耗
  2. https協議在協商密鑰的時候使用非對稱加密,密鑰協商結束後使用對稱加密
  3. 有些場景下,即便經過了https進行了加解密,業務系統也會對報文進行二次加密與簽名
  4. HttpClient在build的時候,鏈接池管理器註冊了兩個SslSocketFactory,用來匹配http或者https字符串
  5. https對應的socket創建原則是先創建,後驗證域名與證書一致性
  6. ssl層加解密由jdk自身完成,不須要httpClient進行額外操做
相關文章
相關標籤/搜索