深刻 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 也能够给咱们从一些方面带来轻量的安全解决思路。感兴趣的朋友能够对这些环节作更加深刻的解读。

请关注个人微信公众号 【半行代码】

相关文章
相关标签/搜索