HTTPS 精读之 TLS 证书校验
HTTPS 协议栈与 HTTP 的唯一区别在于多了一个安全层(Security Layer)—— TLS/SSL,SSL 是最早的安全层协议,TLS 由 SSL 发展而来,所以下面我们统称 TLS。
OkHttp 用一个 enum 类型来表示 TLS 协议的不同版本,可以看到最早的版本是 SSLv3,诞生于 1996 年,最新的版本是 TLSv1.3。
public enum TlsVersion {
TLS_1_3("TLSv1.3"), // 2016.
TLS_1_2("TLSv1.2"), // 2008.
TLS_1_1("TLSv1.1"), // 2006.
TLS_1_0("TLSv1"), // 1999.
SSL_3_0("SSLv3"), // 1996.
;
final String javaName;
}
TLS 握手的作用之一是身份认证(authentication),被验证的一方需要提供一个身份证明,在 HTTPS 的世界里,这个身份证明就是 「TLS 证书」,或者称为 「HTTPS 证书」。
例如,我们在访问 https://www.youzan.com 时,浏览器会得到一个 TLS 证书,这个数字证书用于证明我们正在访问的网站和证书的持有者是匹配的,否则因为身份认证无法通过,连接也就无法建立。
上例可以看出,浏览器得到的是一个证书的链表,这个链表叫证书链(Certificate Chain),我们后面会分析它的作用。
同样道理,OkHttp 请求一个 https 链接时也会得到一个证书链,那我们如何验证 *.http://youzan.com
这个证书是合法的呢?先来分析一下 TLS 证书的格式。
TLS 证书格式
世界上的 CA 机构会遵守 X.509 规范来签发公钥证书(Public Key Certificate),证书内容的语法格式遵守 ASN.1,证书大致包含如下内容:
JDK 中用 java.security.cert.X509Certificate 来表示一个证书,它继承自抽象类 java.scurity.cert.Certificate,通过 X509Certificate 我们可以获取证书的信息,例如,通过如下代码可以得到 Certificate Issuer 的 DN:
caCert.getIssuerX500Principal()
其中 Certificate issuer 是证书的签发者,上例中 *.youzan.com
证书的 issuer 是它的父节点 「Go Daddy Secure Certificate Authority」,issuer 字段的内容是一组符合 X.500 规范的 DN(Distinguished Name):
Issuer: C=US, ST=Arizona, L=Scottsdale, O=GoDaddy.com, Inc., OU=http://certs.godaddy.com/repository/, CN=Go Daddy Secure Certificate Authority - G2
A DN is a sequence of relative distinguished names (RDN) connected by commas.
DN 的属性(等号左侧的值)含义如下所示:
String | Attribute type |
DC | domainComponent |
CN | commonName |
OU | organizationUnitName |
O | organizationName |
STREET | streetAddress |
L | localityName |
ST | stateOrProvinceName |
C | countryName |
UID | userid |
证书里的 Subject's Name
也是一组 DN,它表示证书的拥有者,*.youzan.com
的 Subject 是:
Subject: OU=Domain Control Validated, CN=*.qima-inc.com
X509Certificate
也提供了获取 Subject 的方法:
public X500Principal getSubjectX500Principal() {
if (subjectX500Principal == null) {
subjectX500Principal = X509CertImpl.getSubjectX500Principal(this);
}
return subjectX500Principal;
}
我们通过一个真实的证书来分析一下证书内容(基于 Mac 系统 & Chrome浏览器)。
第一步:从 「Keychain Access」中导出一个证书,格式选择 pem
证书有四种格式,不知道为什么我导出的 cer 文件 openssl 会解析失败。
- Certificate (.cer)
- Privacy Enhanced Mail (.pem)
- Certificate Bundle (.p7b)
- Personal Information Exchange (.p12)
第二步:通过 openssl 命令查看证书内容
openssl x509 -in \*.qima-inc.com.pem -text
以上命令输出了如下证书内容(Public Key 和 Signature 的值太长,用 … 取代)
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
18:3c:86:30:dd:90:c4:f5
Signature Algorithm: sha256WithRSAEncryption
Issuer: C=US, ST=Arizona, L=Scottsdale, O=GoDaddy.com, Inc., OU=http://certs.godaddy.com/repository/, CN=Go Daddy Secure Certificate Authority - G2
Validity
Not Before: Mar 14 10:45:38 2016 GMT
Not After : Mar 14 10:45:38 2019 GMT
Subject: OU=Domain Control Validated, CN=*.qima-inc.com
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
RSA Public Key: (2048 bit)
Modulus (2048 bit):
...
Exponent: 65537 (0x10001)
X509v3 extensions:
X509v3 Basic Constraints: critical
CA:FALSE
X509v3 Extended Key Usage:
TLS Web Server Authentication, TLS Web Client Authentication
X509v3 Key Usage: critical
Digital Signature, Key Encipherment
X509v3 CRL Distribution Points:
URI:http://crl.godaddy.com/gdig2s1-208.crl
X509v3 Certificate Policies:
Policy: 2.16.840.1.114413.1.7.23.1
CPS: http://certificates.godaddy.com/repository/
Policy: 2.23.140.1.2.1
Authority Information Access:
OCSP - URI:http://ocsp.godaddy.com/
CA Issuers - URI:http://certificates.godaddy.com/repository/gdig2.crt
X509v3 Authority Key Identifier:
keyid:40:C2:BD:27:8E:CC:34:83:30:A2:33:D7:FB:6C:B3:F0:B4:2C:80:CE
X509v3 Subject Alternative Name:
DNS:*.qima-inc.com, DNS:qima-inc.com
X509v3 Subject Key Identifier:
FE:97:D6:12:21:F6:C0:31:7E:84:D4:C4:A2:6F:A7:8C:E3:87:EB:8D
Signature Algorithm: sha256WithRSAEncryption
...
可以看出,一个 Certificate 由 Data 和 Signature 两部分组成。
其中 Data 包含的内容有:
- 证书版本号:X.509v3
- 序列号:一个 CA 机构内是唯一的,但不是全局唯一
- 签名算法:签名的计算公式为RSA(sha256(Data), IssuerPrivateKey)
- 签发者:DN(Distinguished Name)
- 有效期:证书的有效期间 [Not Before, Not After]
- 证书拥有者:也是一个 DN公钥长度一般是 2048bit,1024bit已经被证明不安全
- 扩展字段:证书所携带的域名信息会配置在 SAN 中(X509v3 Subject Alternative Name)
Signature 位于证书最末尾,签名算法 sha256WithRSAEncryption 在 Data 域内已经指明 ,而 RSA 进行非对称加密所需的私钥(Private Key)则是由 Issuer 提供,Issuer 是一个可以签发证书的证书,由证书权威 CA 提供,CA 需要保证证书的有效性,而且 CA 的私钥需要绝密保存,一旦泄露出去,证书可能会被随意签发,也就意味 CA 机构要赔很多钱,跟保险理赔类似。
生成签名的公式如下所示:
Signature = RSA(sha256(Data), IssuerPrivateKey)
因为 Signature 是 RSA 算法生成的,那么 UA(User Agent,这里指 OkHttp 这一端)拿到 TLS 证书之后,需要 Issuer 的公钥(Public Key)才能解码出 Data 的摘要。
然而证书只携带了 Issuer 的 DN,并没有公钥,为了弄清楚 UA 如何获取公钥,我们需要先搞明白 Certificate Chain。
证书链(Certificate Chain)
X.509 除了规范证书的内容之外,还规范了如何获取 CRL 以及 Certificate Chain 的验证算法。X.509 规范由国际电信联盟(ITU)定义,RFC 5280 只是定义了 X.509 的用法。
文章最开始,我们访问 https://www.youzan.com 时,浏览器并非只拿到了一个证书,而是一个证书链:
Go Daddy Root Certificate Authority - G2
|__ Go Daddy Secure Certificate Authority - G2
|__ *.youzan.com
G2 的 G 表示 Generation
证书 *.http://youzan.com
的 Issuer 就是它的父节点「Go Daddy Secure Certificate Authority」。因为 UA(浏览器或操作系统)中会预先内置一些权威 CA 签发的根证书(Root Certificate)或中间证书(Intermediate Certificate),例如上面的 「Go Daddy Secure Certificate Authority」和 「Go Daddy Root Certificate Authority」。
当获得证书链之后,我们就可以很轻松的往上回溯到被 UA 信任的证书,虽然 UA 内置的可能是中间证书(Intermediate Certificate),但是如果一个 End-Entity 证书即使回溯到跟证书(Root Certificate)也没有在 UA 的受信列表中找到,那么这个站点就会被标记为不安全,例如 12306 的主页被标记为 “Not Secure”,因为它的根证书不被信任。
TLS Pinning
我们上面所分析的校验方式属于单向校验,仅仅是客户端对服务端证书进行校验,这种方式无法避免中间人攻击(Man-In-the-Middle-Attack)。我们日常开发中用 Charles 抓包时,Charles 就扮演了一个中间人的角色。抓包之前,我们需要在手机上安装一个 Charles 提供的根证书(Root Certificate),这个根证书加入到手机的 Trust Store 之后,它所签发的证书都会被 UA 认作可信。那么 Charles 就可以肆无忌惮地代表真正的 UA 与服务端建立连接,因为是单向认证,所以服务端并不会要求 Charles 提供证书。
但是实现双向校验的成本会比较高,因为 UA 端的证书管理比较复杂,例如证书的获取、有效期管理等等问题,而且需要用户手动添加到 Trust Store,这样也会降低用户体验。
既然双向认证的成本如此之高,那我们不妨利用 SSL Pinning 来解决证书认证被“劫持”的问题。
OkHttp 在 UA 端用一个类 Pin 来表示服务端的 TLS 证书。
static final class Pin {
/** 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;
}
证书的最终的表现形式是一个利用哈希算法(由 hashAlgorithm 字段表示)对证书公钥生成的哈希值(由 hash 字段表示),形式如下:
sha256/afwiKY3RxoMmLkuRW1l7QsPZTJPwDS2pdDROQjXw8ig=
斜杠之前的字符串是 hashAlgorithm
,之后的字符串是 hash
值。
TLS 证书的 Extension 字段中有一个 SAN,用于配置域名,例如 *.http://youzan.com
的证书中配置了两个域名 —— *.http://youzan.com
和 http://youzan.com
,两者所匹配的域名是不同的,所以 Pin
用了一个 pattern
字段来表示两种模式。
我们知道,TLS 证书携带了端的公钥(Public Key),而这个公钥是 TLS 能够通过握手协商出“对称加密密钥”的关键,证书验证仅仅是为了证明当前证书确实是这个公钥的携带者,或者叫 Owner。所以我们只需要用一个 Pin 把服务端证书的公钥存储在本地,当得到证书链(Certificate Chain)之后,用 Pin 里的 hash 去匹配证书的公钥。
因为本地可以配置多个 Pin,因此 OkHttp 用了一个 CertificatePinner 来管理。
CertificatePinner certificatePinner = new CertificatePinner.Builder()
.add("publicobject.com", "sha256/afwiKY3RxoMmLkuRW1l7QsPZTJPwDS2pdDROQjXw8ig=")
.add("publicobject.com", "sha256/klO23nT2ehFDXCfx3eHTDRESMz3asj1muO+4aIdjiuY=")
.add("publicobject.com", "sha256/grX4Ta9HpZx6tSHkmCrvpApTQGo67CYDnvprLg5yRME=")
.add("publicobject.com", "sha256/lCppFqbkrlJ3EcVFAkeip0+44VaoJUymbnOaEUk7tEU=")
.build();
如此一来,在 TLS 握手过程中,校验证书那一步就可以保证服务端下发的证书是客户端想要的,从而避免了被中间人攻击(MIMA),因为本地没有存储中间人证书的 Pin,所以证书匹配会失败,握手也会失败,从而连接无法建立。
总结
关于证书的知识,水还是很深的,本篇只是很粗浅的把证书认证的过程串了起来,还有很多的概念没有涉及到,例如有关证书吊销的 CRL,有关证书管理的 PKI,关于 X.500 规范也只是蜻蜓点水。如果要全部搞明白,恐怕短时间内也做不到,关于证书的事情暂时到此为止,不继续深入了。
参考资料
- https://en.wikipedia.org/wiki/X.500
- https://stackoverflow.com/a/11801944
- https://square.github.io/okhttp/3.x/okhttp/okhttp3/CertificatePinner.html
- https://crypto.stackexchange.com/questions/19093/what-does-g2-mean-when-used-with-x509-certficates-and-certificate-authorities
- https://msdn.microsoft.com/en-us/library/aa366101(v=vs.85).aspx
- https://www.wikihow.com/Export-Certificate-Public-Key-from-Chrome
留下评论