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 的屬性(等號左側的值)含義如下所示:
證書里的 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」。
Chain of trust - from wikipedia
當獲得證書鏈之后,我們就可以很輕松的往上回溯到被 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
規范也只是蜻蜓點水。如果要全部搞明白,恐怕短時間內也做不到,關於證書的事情暫時到此為止,不繼續深入了,接下來一篇想聊聊 TLS 握手,敬請期待:)。
參考鏈接
- 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