深刻 OKHttp 之 TLS

今天咱們來看一下 OKHttp 中是怎麼處理 HTTP 的 TLS 安全鏈接的。 咱們直接分析 RealConnection 的 connectTls 方法:html

private void connectTls(ConnectionSpecSelector connectionSpecSelector) {
	Address address = route.address();
    SSLSocketFactory sslSocketFactory = address.sslSocketFactory();
    boolean success = false;
    SSLSocket sslSocket = null;
    
    // 1. Create the wrapper over the connected socket.
    sslSocket = (SSLSocket) sslSocketFactory.createSocket(
          rawSocket, address.url().host(), address.url().port(), true /* autoClose */);
    
    // 2. Configure the socket's ciphers, TLS versions, and extensions.
    ConnectionSpec connectionSpec = connectionSpecSelector.configureSecureSocket(sslSocket);
    if (connectionSpec.supportsTlsExtensions()) {
        Platform.get().configureTlsExtensions(
            sslSocket, address.url().host(), address.protocols());
    }
    
    // 3. Force handshake. This can throw!
    sslSocket.startHandshake();
    
    // 4. block for session establishment
    SSLSession sslSocketSession = sslSocket.getSession();
    
    Handshake unverifiedHandshake = Handshake.get(sslSocketSession);
    
    // 5.Verify that the socket's certificates are acceptable for the target host.
    if (!address.hostnameVerifier().verify(address.url().host(), sslSocketSession)) {
    	X509Certificate cert = (X509Certificate) unverifiedHandshake.peerCertificates().get(0);
        throw new SSLPeerUnverifiedException("Hostname " + address.url().host() + " not verified:"
            + "\n certificate: " + CertificatePinner.pin(cert)
            + "\n DN: " + cert.getSubjectDN().getName()
            + "\n subjectAltNames: " + OkHostnameVerifier.allSubjectAltNames(cert));
    }
    
    // 6. Check that the certificate pinner is satisfied by the certificates presented.
    address.certificatePinner().check(address.url().host(),
          unverifiedHandshake.peerCertificates());
    
    // 7 Success! Save the handshake and the ALPN protocol.
    String maybeProtocol = connectionSpec.supportsTlsExtensions()
          ? Platform.get().getSelectedProtocol(sslSocket)
          : null;
    
    Platform.get().afterHandshake(sslSocket);
}
複製代碼

TLS 的鏈接有這麼幾個流程:java

  1. 建立 TLS 套接字
  2. 配置 Socket 的加密算法,TLS版本和擴展
  3. 強行進行一次 TLS 握手
  4. 創建 SSL 會話
  5. 校驗證書
  6. 證書鎖定校驗
  7. 若是成功鏈接,保存握手和 ALPN 的協議

建立 TLS 套接字

OKHttp 中,咱們能夠找到,若是是 TLS 鏈接,那麼必定會有一個 SSLSocketFactory ,這個類咱們通常並不會設置。那麼咱們看看默認的是啥:c++

this.sslSocketFactory = systemDefaultSslSocketFactory(trustManager);
複製代碼

繼續能夠跟到 systemDefaultSslSocketFactory 方法:git

SSLContext sslContext = Platform.get().getSSLContext();
sslContext.init(null, new TrustManager[] { trustManager }, null);
return sslContext.getSocketFactory();
複製代碼

能夠看到這裏調用 JDK 的 API 建立了 SSLSocketFactory。github

getSSLContext 方法裏面實例化了一個 SSLContext :算法

SSLContext.getInstance("TLS");
複製代碼

這裏的 protocol 是 "TLS" , 這裏咱們能夠傳入了一個 SSLContextSpio :數組

public static SSLContext getInstance(String protocol) throws NoSuchAlgorithmException {
        GetInstance.Instance instance = GetInstance.getInstance
                ("SSLContext", SSLContextSpi.class, protocol);
        return new SSLContext((SSLContextSpi)instance.impl, instance.provider,
                protocol);
    }
複製代碼

搜索一下源碼。能夠找到 SSLContextSpi 的具體實現類是 OpenSSLContextImpl  這部分 SSL 相關的內容存在一個安全加密相關的三方庫裏,是一個 google 的庫。具體的 github 地址是 github.com/google/cons…瀏覽器

查看他的 getSocketFactory 方法:安全

@Override
public SSLSocketFactory engineGetSocketFactory() {
	if (sslParameters == null) {
		throw new IllegalStateException("SSLContext is not initialized.");
	}
	return Platform.wrapSocketFactoryIfNeeded(new OpenSSLSocketFactoryImpl(sslParameters));
}
複製代碼

這裏其實是直接返回了一個 OpenSSLSocketFactoryImpl 對象。看一下他的 createSocket :服務器

@Override
    public Socket createSocket(Socket socket, String hostname, int port, boolean autoClose) throws IOException {
        Preconditions.checkNotNull(socket, "socket");
        if (!socket.isConnected()) {
            throw new SocketException("Socket is not connected.");
        }

        if (!useEngineSocket && hasFileDescriptor(socket)) {
            return createFileDescriptorSocket(
                    socket, hostname, port, autoClose, (SSLParametersImpl) sslParameters.clone());
        } else {
            return createEngineSocket(
                    socket, hostname, port, autoClose, (SSLParametersImpl) sslParameters.clone());
        }
    }
複製代碼

這裏會返回一個 Java8FileDescriptorSocket 或者 Java8EngineSocket, 其實是 ConscryptFileDescriptorSocket  和 ConscryptEngineSocket 他們都是 OpenSSLSocketImpl 的具體實現。

SSL 相關配置

回過頭繼續看,會進行一些 SSL 相關的配置。包括配置 Socket 的加密算法,TLS版本和擴展等。

ConnectionSpec connectionSpec = connectionSpecSelector.configureSecureSocket(sslSocket);
複製代碼

讓咱們看看 configureSecureSocket 方法作了什麼事情:

for (int i = nextModeIndex, size = connectionSpecs.size(); i < size; i++) {
      ConnectionSpec connectionSpec = connectionSpecs.get(i);
      if (connectionSpec.isCompatible(sslSocket)) {
        tlsConfiguration = connectionSpec;
        nextModeIndex = i + 1;
        break;
      }
    }
複製代碼

這裏會從 connectionSpecs 裏面獲取第一個兼容 SSL 的 ConnectionSpec 賦值給 tlsConfiguration 繼續去 OKHttpClient 看一下默認的 ConnectionSpec 數組:

static final List<ConnectionSpec> DEFAULT_CONNECTION_SPECS = Util.immutableList(
      ConnectionSpec.MODERN_TLS, ConnectionSpec.CLEARTEXT);
複製代碼

第一個就是 MODERN_TLS , 進入查看細節:

public static final ConnectionSpec MODERN_TLS = new Builder(true)
      .cipherSuites(APPROVED_CIPHER_SUITES)
      .tlsVersions(TlsVersion.TLS_1_3, TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0)
      .supportsTlsExtensions(true)
      .build();
複製代碼

內部配置了支持的算法、tls版本,確認支持 tls extensions

最終會經過

Internal.instance.apply(tlsConfiguration, sslSocket, isFallback);
複製代碼

將這些配置設置給 Socket

接下來會執行 tls 擴展的配置:

Platform.get().configureTlsExtensions(sslSocket, address.url().host(), address.protocols());	
複製代碼

查看 Android 上的處理:

(AndroidPlatform#configureTlsExtensions)

// 1. 容許 SNI 和 會話許可證
	if (hostname != null) {
      setUseSessionTickets.invokeOptionalWithoutCheckedException(sslSocket, true);
      setHostname.invokeOptionalWithoutCheckedException(sslSocket, hostname);
    }

	// 可使用 ALPN.
    if (setAlpnProtocols != null && setAlpnProtocols.isSupported(sslSocket)) {
      Object[] parameters = {concatLengthPrefixed(protocols)};
      setAlpnProtocols.invokeWithoutCheckedException(sslSocket, parameters);
    }
複製代碼

這裏有點不是很瞭解,咱們來了解一下這些是什麼東西

SNI

全程是Server Name Indication(服務名稱證實),這個 ssl 擴展容許在同一個 ip 地址上運行多個 SSL 證書。 在沒有 https 的時候,爲了支持一個ip上多個host, 咱們能夠在header裏面去指定 host, 服務端根據不一樣的host,把請求轉發到不一樣的服務。 當使用 https 的時候,SSl 握手以前,header只有握手完成後才能讓服務端拿到本身的 host, 因此服務端根本沒辦法知道同一個ip,須要和哪一個應用進行交互。

Session 許可

SSL 握手過程當中有一個相似 http session的會話概念,來記錄握手過程。複用握手記錄能夠加快握手過程,優化 HTTPS。 Session Ticket 則是客戶端保存握手記錄

ALPN

Application Layer Protocol Negotiation(應用層協議商) ALPN 是客戶端發送所支持的 HTTP 協議列表,由服務端選擇。協商結果是經過 Server Hello 明文發給客戶端

具體能夠參考文章:imququ.com/post/enable…

至於這些特性的實現細節,這裏不作繼續的探究。

SSL 握手

接下來會進行 https 的握手流程 咱們看 ConscryptFileDescriptorSocket 的 startHandshake 方法。代碼很是長,也不必深刻細節,這裏貼一下它的註釋:

/** * Starts a TLS/SSL handshake on this connection using some native methods * from the OpenSSL library. It can negotiate new encryption keys, change * cipher suites, or initiate a new session. The certificate chain is * verified if the correspondent property in java.Security is set. All * listeners are notified at the end of the TLS/SSL handshake. */
複製代碼

這裏使用 openssl 庫中的一些 jni 方法在這個連接上進行 ssl 握手,協商新的加密密鑰、更改密碼套件、啓動新 session。若是在java.security中設置了相應的屬性,則驗證證書鏈。

校驗

接下來會獲取 SSLSession 對象和握手信息   handshake。來作一些校驗。

if (!address.hostnameVerifier().verify(address.url().host(), sslSocketSession)) {
        X509Certificate cert = (X509Certificate) unverifiedHandshake.peerCertificates().get(0);
        throw new SSLPeerUnverifiedException("Hostname " + address.url().host() + " not verified:"
            + "\n certificate: " + CertificatePinner.pin(cert)
            + "\n DN: " + cert.getSubjectDN().getName()
            + "\n subjectAltNames: " + OkHostnameVerifier.allSubjectAltNames(cert));
      }
複製代碼

默認的 Verify 是 OkHostnameVerifier

@Override
  public boolean verify(String host, SSLSession session) {
    try {
      Certificate[] certificates = session.getPeerCertificates();
      return verify(host, (X509Certificate) certificates[0]);
    } catch (SSLException e) {
      return false;
    }
  }
複製代碼

咱們關注有 hostname 的時候的校驗,會跟到以下代碼:

private boolean verifyHostname(String hostname, X509Certificate certificate) {
    hostname = hostname.toLowerCase(Locale.US);
    List<String> altNames = getSubjectAltNames(certificate, ALT_DNS_NAME);
    for (String altName : altNames) {
      if (verifyHostname(hostname, altName)) {
        return true;
      }
    }
    return false;
  }
複製代碼

這裏只要 hostname 和證書的匹配上就經過了驗證。

接下來還有一步:pinner

咱們先分析一下作了啥,代碼見 CertificatePinner 的 check 方法,先看註釋:

/** * Confirms that at least one of the certificates pinned for {@code hostname} is in {@code * peerCertificates}. Does nothing if there are no certificates pinned for {@code hostname}. * OkHttp calls this after a successful TLS handshake, but before the connection is used. * * @throws SSLPeerUnverifiedException if {@code peerCertificates} don't match the certificates * pinned for {@code hostname}. */
複製代碼

確認爲 hostname 固定的證書中至少有一個在 peercertificates 。若是沒有爲這個 hostname 固定的證書,則不執行任何操做。okhttp在 TLS 握手以後使用鏈接以前調用此操做。

那麼到底啥是 ssl pinner呢?

ssl  pinner

在 https 中,若是沒有作雙向校驗,咱們仍然會有中間人攻擊的風險。雙向校驗又會比較複雜。因此,還有一種證書鎖定的辦法來保障安全。

咱們將客戶端的代碼中寫上只接受指定host的證書,不接受操做系統或者瀏覽器內置的 CA 根證書對應的任何證書,經過這種方式,保障了客戶端和服務端通訊的惟一性和安全性。可是CA簽發證書都存在有效期問題,因此缺點是在證書續期後須要將證書從新內置到客戶端中。

除了這種方式,還有一種公鑰鎖定的方式。提取證書中的公鑰內置到客戶端,經過與服務器端對比公鑰值來驗證合法性,而且在證書續期後,公鑰也能夠保持不變,避免了證書鎖定的過時問題。

OKHttp 中就經過 CertificatePinner  這個類來管理 pinner。

先看一下 Pin 對象, 包括

  • hostname 的表達式
  • 規範的hostname
  • hash算法
  • 證書的hash值
static final class Pin {
    private static final String WILDCARD = "*.";
    /** A hostname like {@code example.com} or a pattern like {@code *.example.com}. */
    final String pattern;
    /** The canonical hostname, i.e. {@code EXAMPLE.com} becomes {@code example.com}. */
    final String canonicalHostname;
    /** Either {@code sha1/} or {@code sha256/}. */
    final String hashAlgorithm;
    /** The hash of the pinned certificate using {@link #hashAlgorithm}. */
    final ByteString hash;
}
複製代碼

查看 check 方法:

// 獲取全部的pin
List<Pin> pins = findMatchingPins(hostname);
for (int c = 0, certsSize = peerCertificates.size(); c < certsSize; c++) {
    // 獲取 X509 證書
    X509Certificate x509Certificate = (X509Certificate) peerCertificates.get(c);
    
    ByteString sha1 = null;
    ByteString sha256 = null;

    for (int p = 0, pinsSize = pins.size(); p < pinsSize; p++) {
        Pin pin = pins.get(p);
        
        if (pin.hashAlgorithm.equals("sha256/")) {
            if (sha256 == null) sha256 = sha256(x509Certificate);  // 計算 hash 值
            // hash 值和pin的hash對上了,成功經過check
            if (pin.hash.equals(sha256)) return; // Success!
        } else if (pin.hashAlgorithm.equals("sha1/")) {
            if (sha1 == null) sha1 = sha1(x509Certificate);
            if (pin.hash.equals(sha1)) return; // Success!
        } else {
            throw new AssertionError("unsupported hashAlgorithm: " + pin.hashAlgorithm);
        }
    }
    
    // 若是沒有經過 check, 拋異常
    // 此時作中間人攻擊的時候會失敗
    StringBuilder message = new StringBuilder()
        .append("Certificate pinning failure!")
        .append("\n Peer certificate chain:");
    for (int c = 0, certsSize = peerCertificates.size(); c < certsSize; c++) {
        X509Certificate x509Certificate = (X509Certificate) peerCertificates.get(c);
        message.append("\n ").append(pin(x509Certificate))
          .append(": ").append(x509Certificate.getSubjectDN().getName());
    }
    
    message.append("\n Pinned certificates for ").append(hostname).append(":");
    
    for (int p = 0, pinsSize = pins.size(); p < pinsSize; p++) {
        Pin pin = pins.get(p);
        message.append("\n ").append(pin);
    }
    
    throw new SSLPeerUnverifiedException(message.toString());
}
複製代碼

因此,okhttp 想作一些證書校驗工做,能夠本地存儲一些 Pin 來作。給 OKHttpClient 設置 CertificatePinner 便可:

CertificatePinner cp = new CertificatePinner.Builder()
    .add("hostname", "hash")
    .add()
    // ...
    // ...
    .build();
複製代碼

成功鏈接-確認協議

到這一步就算是成功進行了 SSL 的鏈接。接下來會進行一個 protocol 的選擇:

AndroidPlatform # getSelectedProtocol :

byte[] alpnResult = (byte[]) getAlpnSelectedProtocol.invokeWithoutCheckedException(socket);
return alpnResult != null ? new String(alpnResult, Util.UTF_8) : null;
複製代碼

這裏會經過反射調用一些系統方法獲取咱們須要創建的鏈接協議。若是 maybeProtocol 爲 null,則會降級到 HTTP/1.1

總結

TLS 裏面的水仍是比較深的,包括了鏈接,握手,證書校驗各個環節。閱讀 OKHttp 也能夠給咱們從一些方面帶來輕量的安全解決思路。感興趣的朋友能夠對這些環節作更加深刻的解讀。

請關注個人微信公衆號 【半行代碼】

相關文章
相關標籤/搜索