標籤:
很多文章對用戶端https的使用都是很模糊的,不但如此,有些開發人員直接從網上拷貝一些使用https的“漏洞”代碼,無形之中讓用戶端處在一種高風險的情況下。
今天我們就對有關https使用的問題進行深入的探討,希望能解決以往的困惑。對於https,需要瞭解其工作原理的可以參考https是如何工作的?,更多關於https的問題我會站在用戶端的角度在後面陸陸續續的寫出來。
認證鎖定 簡介
首先來說說什麼是認證鎖定。
認證鎖定是用來限制哪些認證和憑證授權單位是可信任的。需要我們直接在代碼中固定寫死使用某個伺服器的認證,然後用自訂的信任儲存去代替系統系統內建的,再去串連我們的伺服器,我們將這種做法稱之為認證鎖定。換言之,認證鎖定就是在代碼中驗證當前伺服器是否持有某張指定的認證,如果不是則強行取消連結。
有同學問認證鎖定有什麼好處嗎?最大的好處使用認證鎖定提高安全性,降低了成本。為什麼這麼說呢?如果你想破解該通訊,需要首先拿到用戶端,然後對其反編譯,修改後再重新打包簽名,相比原先的做法,這無疑是增加了破解難度。除了之外,由於認證鎖定可以使用自簽名的認證,那就意味著我們不需要再向android認可的憑證授權單位購買認證了,這樣就可以剩下每年1000多塊錢的認證費用,能省一點就省一點嘛。
retrofit中使用認證鎖定
現在,我們來看看如何在retrofit中進行認證鎖定。
OkHttpClient client = new OkHttpClient.Builder() .certificatePinner(new CertificatePinner.Builder() .add("sbbic.com", "sha1/C8xoaOSEzPC6BgGmxAt/EAcsajw=") .add("closedevice.com", "sha1/T5x9IXmcrQ7YuQxXnxoCmeeQ84c=") .build())
通過上面的代碼不難看出,retrofit中的認證鎖定同樣是藉助OkHttpClient實現的:通過為OkHttpClient添加CertificatePinner即可。CertificatePinner對象以構建器的方式建立,可以通過其add()方法來鎖定多個認證。
認證鎖定的原理
現在我們深入下認證鎖定的原理。我們知道,無論http還是https,都是服務端被動,用戶端主動。那麼問題來了,用戶端第一次發出請求之後,無法確定服務端是不是合法的。那麼很可能就會出現以下情景:
正常情況下是這樣,我們想要根據文章aid查看某篇文章內容,其流程如下:
此時,如果駭客惡意攔截這個通訊過程,會是怎麼樣?
此時惡意服務端完全可以發起雙向攻擊:對上可以欺騙服務端,對下可以欺騙用戶端,更嚴重的是用戶端段和服務端完全感知不到已經被攻擊了。這就是所謂的中間人攻擊。
中間人攻擊(MITM攻擊)是指,駭客攔截並篡改網路中的通訊資料。又分為被動MITM和主動MITM,被動MITM只竊取通訊資料而不修改,而主動MITM不當能竊取資料,還會篡改通訊資料。最常見的中間人攻擊常常發生了公用wifi或者公用路由上,有興趣的私下可以問我,這裡不做示範了。
現在可以看看認證鎖定是怎麼樣提高安全性,避免中間人攻擊的,用一張簡單的流程圖來說明:
不難看出,通過認證鎖定能有有效避免中間人攻擊。
認證鎖定的缺點
認證鎖定儘管帶了較高的安全性,但是這種安全性的提高卻犧牲了靈活性。一旦當認證發生變化時,我們的用戶端也必須隨之升級,除此之外,我們的服務端不得不為了相容以前的用戶端而做出一些妥協或者說直接停用以前的用戶端,這對開發人員和使用者來說並不是那麼的友好。
但實際上,極少情況下我們才會變動認證。因此,如果產品安全性要求比較高還是啟動認證鎖定吧。
使用android認可的憑證授權單位頒發的認證
有些同學可能好奇自己公司中使用https,但是在用戶端代碼中並沒有書寫綁定認證的代碼?以訪問github的代碼為例:
public void loadData() { Retrofit retrofit = new Retrofit.Builder().baseUrl("https://api.github.com/").build(); GitHubApi api = retrofit.create(GitHubApi.class); Call<ResponseBody> call = api.contributorsBySimpleGetCall(mUserName, mRepo); call.enqueue(new Callback<ResponseBody>() { @Override public void onResponse(Call<ResponseBody> call, Response<ResponseBody> response) { // handle response } @Override public void onFailure(Call<ResponseBody> call, Throwable t) { // handle failure } }); }
在https是如何工作的一文中我們說過android已經幫我們預置了150多個認證,這些認證你可以在設定->安全->信任的憑據中看到(在windows中,你可以在命令列中開啟certmgr.msc來開啟Cert Manager,這裡你可以看看windows預置的認證)。現在可以明白了,之所以沒有內建認證的原因在於:我們服務端用的認證是從android認可的憑證授權單位購買的認證,在android中已經內建了這些認證,而預設情況下,retrofit 2.0 所依賴的okhttp 3.0 是信任它們,因此可以直接存取而無需在用戶端設定什麼。
使用非android認可的憑證授權單位頒發的認證或自我簽署憑證
購買認證畢竟是花錢的,現在免費的認證有少之又少,因此使用自我簽署憑證就是另外一種常見的方式了。什麼是自我簽署憑證呢?所謂的自我簽署憑證就是沒有通過受信任的憑證授權單位,自己給自己頒發的認證(下文中,我將非android認可的憑證授權單位頒發的認證也歸為自我簽署憑證)。最典型的就是12306火車購票,使用的認證就不是受信任的憑證授權單位頒發的,而是旗下SRCA(中鐵數位憑證認證中心,簡稱中鐵CA,它是鐵道自己搞的機構,因此相當於自己給自己頒發認證)頒發的認證,如:
SSL認證分為三類:
1. 由android認可的憑證授權單位或者該結構下屬的機構頒發的認證,比如Symantec,Go Daddy等機構,約150多個。更多的自行在手機“設定->安全->信任的憑據”中查看
2.沒有被android所認可的認證所頒發的認證
3. 自己頒發的認證
這三類認證中,只有第一種在使用中不會出現安全提示,不會拋出異常。
由於我們使用的是自簽名的認證,因此用戶端不信任伺服器,會拋出異常:javax.net.ssl.SSLHandshakeException:.為此,我們需要自訂信任處理器(TrustManager)來替代系統預設的信任處理器,這樣我們才能正常的使用自訂的正說或者非android認可的憑證授權單位頒發的認證。
針對使用情境,又分為以下兩種情況:一種是安全性要求不高的情況下,用戶端無需內建認證;另外一種則是用戶端內建認證。
下面我會針對這兩種情況說明其中一些問題點。
用戶端不內建認證
我們知道由於我們使用的是自簽名的認證,所以需要自訂TrustManager,那麼很多開發人員的處理策略非常簡單粗暴:讓用戶端不對伺服器憑證做任何驗證,其實現代碼如下:
public static SSLSocketFactory getSSLSocketFactory() throws Exception { //建立一個不驗證憑證鏈結的認證信任管理器。 final TrustManager[] trustAllCerts = new TrustManager[]{new X509TrustManager() { @Override public void checkClientTrusted( java.security.cert.X509Certificate[] chain, String authType) throws CertificateException { } @Override public void checkServerTrusted( java.security.cert.X509Certificate[] chain, String authType) throws CertificateException { } @Override public java.security.cert.X509Certificate[] getAcceptedIssuers() { return new java.security.cert.X509Certificate[0]; } }}; // Install the all-trusting trust manager final SSLContext sslContext = SSLContext.getInstance("TLS"); sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); // Create an ssl socket factory with our all-trusting manager return sslContext .getSocketFactory(); } //使用自訂SSLSocketFactory private void onHttps(OkHttpClient.Builder builder) { try { builder.sslSocketFactory(getSSLSocketFactory()).hostnameVerifier(org.apache.http.conn.ssl.SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER); } catch (Exception e) { e.printStackTrace(); } }
上面的代碼不要輕易的應用在實際工程中,除非你能容忍他的危害。為什麼這麼說呢?繼續往下看。
在上面的代碼中,我們自行實現X509TrustManager時並沒有對其中三個核心的方法進行 具體實現(主要是沒有在checkServerTrusted()驗證認證),這樣做相當於直接忽略了檢驗服務端認證。因此無論伺服器的認證如何,都能建立起https連結。
看起來好像解決了我們的問題,實則帶來更大的危害。此時,雖然能建立HTTPS串連,但是無形之中間人攻擊開啟了一道門。此時,駭客完全可以攔截到我們的HTTPS請求,然後用偽造的認證冒充真正服務端的數位憑證,由於用戶端不對認證做驗證(也就沒法判斷服務端到底是正常的還是偽造的),這樣用戶端就會和駭客的伺服器建立串連。這就相當於你以為你對的對面是個美女,卻沒有想到已經被掉包了,想想“狸貓換太子”就明白了。(對這點不明白的同學,可以參見認證鎖定中的樣本。)
那麼怎麼避免呢?我們需要在自訂TrustManager時重寫checkServerTrusted()方法,並在該方法中校正認證,完善後的代碼如下:
public static SSLSocketFactory getSSLSocketFactory() throws Exception { // Create a trust manager that does not validate certificate chains final TrustManager[] trustAllCerts = new TrustManager[]{new X509TrustManager() { //認證中的公開金鑰 public static final String PUB_KEY = "3082010a0282010100d52ff5dd432b3a05113ec1a7065fa5a80308810e4e181cf14f7598c8d553cccb7d5111fdcdb55f6ee84fc92cd594adc1245a9c4cd41cbe407a919c5b4d4a37a012f8834df8cfe947c490464602fc05c18960374198336ba1c2e56d2e984bdfb8683610520e417a1a9a5053a10457355cf45878612f04bb134e3d670cf96c6e598fd0c693308fe3d084a0a91692bbd9722f05852f507d910b782db4ab13a92a7df814ee4304dccdad1b766bb671b6f8de578b7f27e76a2000d8d9e6b429d4fef8ffaa4e8037e167a2ce48752f1435f08923ed7e2dafef52ff30fef9ab66fdb556a82b257443ba30a93fda7a0af20418aa0b45403a2f829ea6e4b8ddbb9987f1bf0203010001"; @Override public void checkClientTrusted( java.security.cert.X509Certificate[] chain, String authType) throws CertificateException { } //用戶端並為對ssl認證的有效性進行校正 @Override public void checkServerTrusted( java.security.cert.X509Certificate[] chain, String authType) throws CertificateException { if (chain == null) { throw new IllegalArgumentException("checkServerTrusted:x509Certificate array isnull"); } if (!(chain.length > 0)) { throw new IllegalArgumentException("checkServerTrusted: X509Certificate is empty"); } if (!(null != authType && authType.equalsIgnoreCase("RSA"))) { throw new CertificateException("checkServerTrusted: AuthType is not RSA"); } // Perform customary SSL/TLS checks try { TrustManagerFactory tmf = TrustManagerFactory.getInstance("X509"); tmf.init((KeyStore) null); for (TrustManager trustManager : tmf.getTrustManagers()) { ((X509TrustManager) trustManager).checkServerTrusted(chain, authType); } } catch (Exception e) { throw new CertificateException(e); } // Hack ahead: BigInteger and toString(). We know a DER encoded Public Key begins // with 0×30 (ASN.1 SEQUENCE and CONSTRUCTED), so there is no leading 0×00 to drop. RSAPublicKey pubkey = (RSAPublicKey) chain[0].getPublicKey(); String encoded = new BigInteger(1 /* positive */, pubkey.getEncoded()).toString(16); // Pin it! final boolean expected = PUB_KEY.equalsIgnoreCase(encoded); if (!expected) { throw new CertificateException("checkServerTrusted: Expected public key: " + PUB_KEY + ", got public key:" + encoded); } } @Override public java.security.cert.X509Certificate[] getAcceptedIssuers() { return new java.security.cert.X509Certificate[0]; } }}; // Install the all-trusting trust manager final SSLContext sslContext = SSLContext.getInstance("TLS"); sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); // Create an ssl socket factory with our all-trusting manager return sslContext .getSocketFactory(); }
其中PUB_KEY是我們認證中的公開金鑰,你可以自行從自己的認證中提取。我們看到,在checkServerTrusted()方法中,我們通過認證的公開金鑰資訊來確認認證的真偽,如果驗證失敗,則插斷要求。當然,此處加入認證的有效期間會更加的完善,實現起來比較簡單,這裡就不做說明了。
除了上面那種在checkServerTrusted()實現認證驗證的方式之外,我們也可以利用retrofit中CertificatePinner來實現認證鎖定,同樣也能達到我們的目的。
用戶端內建認證
如果我們使用的是自我簽署憑證,那麼用戶端中的retrofit該如何進行設定呢?關鍵還是我們上文提到的TrustManager。在retrofit中使用自我簽署憑證大致要經過以下幾步:
- 將認證添加到工程中
- 自訂信任管理器TrustManager
- 用自訂TrustManager代替系統預設的信任管理器
我們按步驟進行說明。
添加認證到工程
比如現在我們有個認證media.bks,首先需要將其放在res/raw目錄下,當然你可以可以放在assets目錄下。
我們知道java本身支援的認證格式jks,但是遺憾的是在android當中並不支援jks格式正式,而是需要bks格式的認證。因此我們需要將jks認證轉換成bks格式認證,關於jks轉bks不再本文做重點說明
自訂TrustManager
和上面不同的是,這裡需要實現本地認證的載入,具體見代碼:
protected static SSLSocketFactory getSSLSocketFactory(Context context, int[] certificates) { if (context == null) { throw new NullPointerException("context == null"); } //CertificateFactory用來認證產生 CertificateFactory certificateFactory; try { certificateFactory = CertificateFactory.getInstance("X.509"); //Create a KeyStore containing our trusted CAs KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); keyStore.load(null, null); for (int i = 0; i < certificates.length; i++) { //讀取本地認證 InputStream is = context.getResources().openRawResource(certificates[i]); keyStore.setCertificateEntry(String.valueOf(i), certificateFactory.generateCertificate(is)); if (is != null) { is.close(); } } //Create a TrustManager that trusts the CAs in our keyStore TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); trustManagerFactory.init(keyStore); //Create an SSLContext that uses our TrustManager SSLContext sslContext = SSLContext.getInstance("TLS"); sslContext.init(null, trustManagerFactory.getTrustManagers(), new SecureRandom()); return sslContext.getSocketFactory(); } catch (Exception e) { } return null; }
用自訂TrustManager代替系統預設的信任管理器
private void onHttpCertficates(OkHttpClient.Builder builder) { int[] certficates = new int[]{R.raw.media}; builder.socketFactory(getSSLSocketFactory(AppContext.context(), certficates)); }
這樣我們就可以的用戶端就可以使用自簽名的認證了。其實不難發現,使用非android認證憑證授權單位頒發的認證的關鍵在於:修改android中SSLContext內建的TrustManager以便能讓我們的簽名通過驗證。
暫告一段落
本文中簡單首先介紹了認證鎖定的使用、原理及優缺點,接著對用戶端使用自訂認證中的一些點做了介紹,希望能協助各位打造安全的安卓用戶端。
另外,大多數情況下,我建議使用認證鎖定來提高安全性。關於雙向認證驗證,後續有時間再補充。
急速開發系列——Retrofit中如何正確的使用https?