深入 OKHttp 之 TLS

今天我们来看一下 OKHttp 中是怎么处理 HTTP 的 TLS 安全连接的。 我们直接分析 RealConnectionconnectTls 方法:

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 的连接有这么几个流程:

  1. 创建 TLS 套接字
  2. 配置 Socket 的加密算法,TLS版本和扩展
  3. 强行进行一次 TLS 握手
  4. 建立 SSL 会话
  5. 校验证书
  6. 证书锁定校验
  7. 如果成功连接,保存握手和 ALPN 的协议

创建 TLS 套接字

OKHttp 中,我们可以找到,如果是 TLS 连接,那么一定会有一个 SSLSocketFactory ,这个类我们一般并不会设置。那么我们看看默认的是啥:

this.sslSocketFactory = systemDefaultSslSocketFactory(trustManager);
复制代码

继续可以跟到 systemDefaultSslSocketFactory 方法:

SSLContext sslContext = Platform.get().getSSLContext();
sslContext.init(null, new TrustManager[] { trustManager }, null);
return sslContext.getSocketFactory();
复制代码

可以看到这里调用 JDK 的 API 创建了 SSLSocketFactory。

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

我们先分析一下做了啥,代码见 CertificatePinnercheck 方法,先看注释:

/**
   * 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 也可以给我们从一些方面带来轻量的安全解决思路。感兴趣的朋友可以对这些环节做更加深入的解读。

请关注我的微信公众号 【半行代码】

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章