HTTPS 简介

HTTPS 全称 HTTP over TLS。TLS是在传输层上层的协议,应用层的下层,作为一个安全层而存在,翻译过来一般叫做传输层安全协议。

对 HTTP 而言,安全传输层是透明不可见的,应用层仅仅当做使用普通的 Socket 一样使用 SSLSocket 。

TLS是基于 X.509 认证,他假定所有的数字证书都是由一个层次化的数字证书认证机构发出,即 CA。另外值得一提的是 TLS 是独立于 HTTP 的,任何应用层的协议都可以基于 TLS 建立安全的传输通道,如 SSH 协议。

代入场景

假设现在 A 要与远端的 B 建立安全的连接进行通信。

  1. 直接使用对称加密通信,那么密钥无法安全的送给 B 。
  2. 直接使用非对称加密,B 使用 A 的公钥加密,A 使用私钥解密。但是因为B无法确保拿到的公钥就是A的公钥,因此也不能防止中间人攻击。

CA

为了解决上述问题,引入了一个第三方,也就是上面所说的 CA(Certificate Authority)。
CA 用自己的私钥签发数字证书,数字证书中包含A的公钥。然后 B 可以用 CA 的根证书中的公钥来解密 CA 签发的证书,从而拿到合法的公钥。那么又引入了一个问题,如何保证 CA 的公钥是合法的呢。答案就是现代主流的浏览器会内置 CA 的证书。

中间证书

当然,现在大多数CA不直接签署服务器证书,而是签署中间CA,然后用中间CA来签署服务器证书。这样根证书可以离线存储来确保安全,即使中间证书出了问题,可以用根证书重新签署中间证书。

校验过程

那么实际上,在 HTTPS 握手开始后,服务器会把整个证书链发送到客户端,给客户端做校验。校验的过程是要找到这样一条证书链,链中每个相邻节点,上级的公钥可以校验通过下级的证书,链的根节点是设备信任的锚点或者根节点可以被锚点校验。那么锚点对于浏览器而言就是内置的根证书啦。请注意上文的说辞,根节点并不一定是根证书,下面会有说明。

校验通过后,视情况校验客户端,以及确定加密套件和用非对称密钥来交换对称密钥。从而建立了一条安全的信道。

HTTPS API

SSLSocketFactory

Android 使用的是 Java 的 API。那么 HTTPS 使用的 Socket 必然都是通过SSLSocketFactory 创建的 SSLSocket,当然自己实现了 TLS 协议除外。

一个典型的使用 HTTPS 方式如下:

URL url = new URL("https://google.com");
HttpsURLConnection urlConnection = url.openConnection();
InputStream in = urlConnection.getInputStream();

此时使用的是默认的SSLSocketFactory,与下段代码使用的SSLContext是一致的

private synchronized SSLSocketFactory getDefaultSSLSocketFactory() {
  try {
    SSLContext sslContext = SSLContext.getInstance("TLS");
    sslContext.init(null, null, null);
    return defaultSslSocketFactory = sslContext.getSocketFactory();
  } catch (GeneralSecurityException e) {
    throw new AssertionError(); // The system has no TLS. Just give up.
  }
}

默认的 SSLSocketFactory 校验服务器的证书时,会信任设备内置的100多个根证书。

TrustManager

上文说了,SSL 握手开始后,会校验服务器的证书,那么其实就是通过 X509ExtendedTrustManager 做校验的,更一般性的说是 X509TrustManager :

/**
 * The trust manager for X509 certificates to be used to perform authentication
 * for secure sockets.
 */
public interface X509TrustManager extends TrustManager {

    public void checkClientTrusted(X509Certificate[] chain, String authType)
            throws CertificateException;

    public void checkServerTrusted(X509Certificate[] chain, String authType)
            throws CertificateException;

    public X509Certificate[] getAcceptedIssuers();
}

那么最后校验服务器证书的过程会落到 checkServerTrusted 这个函数,如果校验没通过会抛出 CertificateException 。笔者不得不得吐槽一下,很多博客说,配置 SSL 差不多是这样的:

private static synchronized SSLSocketFactory getDefaultSSLSocketFactory() {
    try {
        SSLContext sslContext = SSLContext.getInstance("TLS");
        sslContext.init(null, new TrustManager[]{
                new X509TrustManager() {
                    public void checkClientTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {

                    }

                    public void checkServerTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {
                    }

                    public X509Certificate[] getAcceptedIssuers() {
                        return new X509Certificate[0];
                    }
                }
        }, null);
        return sslContext.getSocketFactory();
    } catch (GeneralSecurityException e) {
        throw new AssertionError();
    }
}

好的,如果你这么用的话,随便什么证书你都会信任,网络毫无安全可言,可以随意的被中间人攻击,所以千万不要这样做

SSL的配置

自定义信任策略

如果不清楚怎么配置 SSL ,最好的办法就是不配置他,系统会为你配置好一个安全的 SSL 。

但是如果用系统默认的 SSL,那么就是假设一切 CA 都是可信的。可往往 CA 有时候也不可信,比如某家 CA 被黑客入侵什么的事屡见不鲜。虽然 Android 系统自身可以更新信任的 CA 列表,以防止一些 CA 的失效。那么为了更高的安全性,我们希望指定信任的锚点,可以类似采用如下的代码:

// 取到证书的输入流
InputStream is = new FileInputStream("anchor.crt");
CertificateFactory cf = CertificateFactory.getInstance("X.509");
Certificate ca = cf.generateCertificate(is);

// 创建 Keystore 包含我们的证书
String keyStoreType = KeyStore.getDefaultType();
KeyStore keyStore = KeyStore.getInstance(keyStoreType);
keyStore.load(null);
keyStore.setCertificateEntry("anchor", ca);

// 创建一个 TrustManager 仅把 Keystore 中的证书 作为信任的锚点
String algorithm = TrustManagerFactory.getDefaultAlgorithm();
TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(algorithm);
trustManagerFactory.init(keyStore);
TrustManager[] trustManagers = trustManagerFactory.getTrustManagers();

// 用 TrustManager 初始化一个 SSLContext
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustManagers, null);
return sslContext.getSocketFactory();

那么只有我们的 anchor.crt 才会作为信任的锚点,只有 anchor.crt 以及他签发的证书才会被信任。

说起来有个很有趣的玩法,考虑到证书会过期、升级,我们既不想只信任我们服务器的证书,又不想信任 Android 所有的 CA 证书。有个不错的的信任方式是把签发我们服务器的证书的根证书导出打包到 APK 中,然后用上述的方式做信任处理。

仔细思考一下,这未尝不是一种好的方式。只要日后换证书还用这家 CA 签发,既不用担心失效,安全性又有了一定的提高。因为比起信任100多个根证书,只信任一个风险会小很多。

正如最开始所说,信任锚点未必需要根证书。因此同样上面的代码也可以用于自签名证书的信任,相信看官们能举一反三,就不再多述。

注意点

服务器下发证书不全

上文提到现在大多数的场景是根证书离线存储,使用二级证书签发服务器证书。而系统默认是只信任根证书的,因此就产生了一个小小的信任的缝隙。

如果服务器下发证书的时候没有发送一条证书链,而是只发了自己的证书,那么信任链就因为缺一环而导致校验会失败。

一般发现这种情况笔者只建议去联系运维的同学去配置服务器而不会在应用端做任何更改。

域名校验

Android 内置的 SSL 的实现是引入了Conscrypt 项目,而 HTTP(S)层则是使用的2.x的 OkHttp。

而 SSL 层只负责校验证书的真假,对于所有基于SSL 的应用层协议,需要自己来校验证书实体的身份,因此 Android 默认的域名校验则由 OkHostnameVerifier 实现的,从 HttpsUrlConnection 的代码可见一斑:

static {
    try {
        defaultHostnameVerifier = (HostnameVerifier)
                Class.forName("com.android.okhttp.internal.tls.OkHostnameVerifier")
                .getField("INSTANCE").get(null);
    } catch (Exception e) {
        throw new AssertionError("Failed to obtain okhttp HostnameVerifier", e);
    }
}

如果校验规则比较特殊,可以传入自定义的校验规则给 HttpsUrlConnection。

同样,如果要基于 SSL 实现其他的应用层协议,千万别忘了做域名校验以证明证书的身份。

证书固定

上文自定义信任锚点的时候说了一个很有意思的方式,只信任一个根CA,其实更加一般化和灵活的做法就是用证书固定。

其实 HTTPS 是支持证书固定技术的(CertificatePinning),通俗的说就是对证书公钥做校验,看是不是符合期望。

HttpsUrlConnection 并没有对外暴露相关的API,而在 Android 大放光彩的 OkHttp 是支持证书固定的,虽然在 Android 中,OkHttp 默认的 SSL 的实现也是调用了 Conscrypt,但是重新用 TrustManager 对下发的证书构建了证书链,并允许用户做证书固定。具体 API 的用法可见 CertificatePinner 这个类,这里不再赘述。

小结

安全无小事,尤其是网络通信方面。希望本文能给诸位读者一些小小的启发。最后断更了那么久,实在是抱歉。坚持写博客确实不易,新的一年笔者会努力的。

最后欢迎关注笔者的微信公众号,会不定期的分享一些内容给大家

感谢大家的支持!