2019年7月7日 星期日

Java 的 SSL 驗證

一般在 Java 要存取 HTTPS 的服務的時候,大多數狀況因為常見的 CA 應該都已經列在 JRE 的 cacerts(CA Certification 檔)裡了,所以通常不會遇到什麼問題。不過如果遇到要存取的是像是公司內部的服務,公司自己有自己的 CA 時,可能就得做點事情來讓 HTTPS 的 SSL/TLS 能夠正常運作。

概念上來說,當一個客戶端要存取 HTTPS 的服務時,客戶端只會相信它相信的對象。而要如何決定要相信誰?方法就是客戶端自己會存放一個信任的 CA 的清單,如果發現 HTTPS 服務的憑證是由信任的 CA 所發放的、並且憑證所保護的對象也確實是現在存取的對象,那客戶端就會相信這個 HTTPS 服務。以 Java 的狀況來說,客戶端指的是 JRE,JRE 內建的信任 CA 的清單會是 $JAVA_HOME/lib/security/cacerts 這個檔案。如果沒有特別指定的話,Java 程式在存取 HTTPS 時,進行 SSL/TLS 三方握手時就會以 JRE 內建的 cacerts 來決定對方是否能夠信任。

不過現實上,CA 還會分成 Root Certificate 和 Intermediate Certificate,例如 [1] 的範例一樣:

這裡 Entrust.net Secure Server CA 是 Root Certificate,DigiCert High Assurance EV Root CA 和 DigiCert High Assurance CA-3 則都是 Intermediate Certificate,最後 *.atlassian.com 這個網域的憑證是由 DigiCert High Assurance CA-3 所簽發的。客戶端若要驗證 *.atlassian.com 的憑證,它必須要信任上面的所有 CA(也稱為 Certificate Chain)才行。

Java 中遇到憑證問題的徵兆

當我們在 Java 中遇到憑證問題時,大多會出現類似這樣的錯誤訊息:

javax.net.ssl.SSLHandshakeException: sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
    at org.apache.http.conn.ssl.SSLSocketFactory.createLayeredSocket(SSLSocketFactory.java:573)
    at org.apache.http.conn.ssl.SSLSocketFactory.connectSocket(SSLSocketFactory.java:557)
    at org.apache.http.conn.ssl.SSLSocketFactory.connectSocket(SSLSocketFactory.java:414)

看到這類的訊息時,差不多就可以判定很有可能是 HTTPS 驗證沒有通過。

檢驗憑證的工具

想要檢查問題,可以使用的工具大概是 curlopensslkeytool 等等,因為實務上錯誤大多只會告訴我們憑證驗證有誤,但它不會具體地說要怎麼解決,所以我們通常只能透過旁敲側擊來嘗試。但話是這麼說啦,實際上要解決問題,方法大概不外乎就是兩種:

  1. 讓程式碼忽略 HTTPS 驗證的檢查
  2. 讓程式碼能夠讀到正確的 CA Certificate

所以這邊要紀錄的,其實是看看憑證裡到底長什麼樣子。

openssl s_client -showcerts -connect HOSTNAME:PORT

這個指令可以查看指定的 HOST : PORT 的 Certificate chain,例如下面是 tw.yahoo.com:443 的狀況:

---
Certificate chain
 0 s:/C=US/ST=California/L=Sunnyvale/O=Oath Inc/CN=*.www.yahoo.com
   i:/C=US/O=DigiCert Inc/OU=www.digicert.com/CN=DigiCert SHA2 High Assurance Server CA
-----BEGIN CERTIFICATE-----
....
-----END CERTIFICATE-----
 1 s:/C=US/O=DigiCert Inc/OU=www.digicert.com/CN=DigiCert SHA2 High Assurance Server CA
   i:/C=US/O=DigiCert Inc/OU=www.digicert.com/CN=DigiCert High Assurance EV Root CA
-----BEGIN CERTIFICATE-----
....
-----END CERTIFICATE-----
 2 s:/C=US/O=DigiCert Inc/OU=www.digicert.com/CN=DigiCert SHA2 High Assurance Server CA
   i:/C=US/O=DigiCert Inc/OU=www.digicert.com/CN=DigiCert High Assurance EV Root CA
-----BEGIN CERTIFICATE-----
....
-----END CERTIFICATE-----
---
Server certificate
subject=/C=US/ST=California/L=Sunnyvale/O=Oath Inc/CN=*.www.yahoo.com
issuer=/C=US/O=DigiCert Inc/OU=www.digicert.com/CN=DigiCert SHA2 High Assurance Server CA
---

keytool -list -v –keystore $JAVA_HOME/jre/lib/security/cacerts -storepass changeit

用 keytool 的這個 list 指令,是告訴它說我想看某個 CA Certificate,請它顯示詳細資訊(-v)給我,並且這時要告訴它 CA Certificate 的清單是存放在 –keystore 這個參數的位置,解開 keystore 的密碼是 changeit。不過實務上要執行這個指令時,可能必須要把 $JAVA_HOME 代換成實體路徑。

因為前面有提到,cacerts 是一個 CA Certificate 的清單,但我們在指令上並沒有要求要印出特定的憑證,因此這時它會把所有 JRE 內建的 CA Certificate 全列出。例如我這邊會看到:

別名名稱: cert_29
建立日期: 2017/9/3
項目類型: trustedCertEntry

擁有者: CN=login.yahoo.com, OU=PlatinumSSL, OU=Hosted by GTI Group Corporation, OU=Tech Dept., O=Google Ltd., STREET=Sea Village 10, L=English, ST=Florida, OID.2.5.4.17=38477, C=US
發行人: CN=UTN-USERFirst-Hardware, OU=http://www.usertrust.com, O=The USERTRUST Network, L=Salt Lake City, ST=UT, C=US
序號: d7558fdaf5f1105bb213282b707729a3
有效期自: Tue Mar 15 08:00:00 CST 2011 到: Sat Mar 15 07:59:59 CST 2014
憑證指紋:
	 MD5:  0C:1F:BE:D3:FC:09:6E:E6:6E:C2:66:39:75:86:6B:EB
	 SHA1: 63:FE:AE:96:0B:AA:91:E3:43:CE:2B:D8:B7:17:98:C7:6B:DB:77:D0
	 SHA256: 93:3F:7D:8C:DA:9F:0D:7C:8B:FD:3C:22:BF:46:53:F4:16:1F:D3:8C:CD:CF:66:B2:2E:95:A2:F4:9C:26:50:F8
簽章演算法名稱: SHA1withRSA
主體公開金鑰演算法: 2048 位元的 RSA 金鑰
版本: 3

這邊就注意到,最開頭的「別名名稱」(alias name),是這個 CA Certificate 在 cacerts 檔案中的名字,因此我們可以在 keytool 指令上額外加入 –alias cert_29,就可以單獨只看別名是 cert_29 的這個憑證了。

綜合這兩個工具,雖然也不是很便利,不過其實還是可以比對一下在 cacerts 裡面到底有沒有所需要的憑證。

解決 SSLHandshakingException 問題

基本上其實上面的 SSLHandshakingException 會發生的時候,肯定就是要求的憑證找不到了~所以要解決問題時大概只有兩種策略:

  1. 讓程式直接忽略憑證以及 host name 的檢查。
  2. 把指定的憑證匯入程式。

第一種就不多說了,很久以前有寫過相關的文章,而且網路上這類資料非常容易找。第二種的話,具體來說大概有兩種作法,一種是在執行 Java 的指令上透過 -Djavax.net.ssl.trustStore 指定另外一個 truststore;另一種就是透過 keytool 工具把憑證匯入到 JRE 預設的 cacerts 檔案裡了。

keytool -importcert -alias mycert -file ca.cert.pem -keystore $JAVA_HOMEjre/lib/security/cacerts

在這個指令中,要求 keytool 執行匯入憑證(importcert)的行為,其中匯入的憑證要給它一個別名叫做 mycert,憑證檔是 ca.cert.pem,匯入的目的地是 JRE 內建的 cacerts 檔案中。匯入後,如果憑證本身是正確的,那麼這樣就會解決 SSLHandshakingException 的問題了。

不過我也遇過憑證本身是個 certiificate bundle,執行匯入時 keytool 只會匯入其中一個(可能是第一個吧)憑證,所以導致我明明執行了匯入,結果卻依然沒解決問題的現象~。這時就可以利用上面講到的工具來檢查問題,例如我就是用了上面講到的查閱指令查詢我匯入的憑證(加上 –alias 指定我要看我剛剛匯入的憑證),結果發現上面顯示出來的憑證資訊跟我想要的不一樣….。

keytool -list -v -alias mycert –keystore $JAVA_HOME/jre/lib/security/cacerts -storepass changeit
參考資料
  1. Unable to connect to SSL services due to "PKIX Path Building Failed" error
  2. Diagnosing TLS, SSL, and HTTPS
  3. Java keytool 基本指令介紹

沒有留言: