Retrofit中如何正確的使用https?

yangxi_001發表於2017-06-20

很多文章對客戶端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())
  • 1
  • 2
  • 3
  • 4
  • 5
  • 1
  • 2
  • 3
  • 4
  • 5

通過上面的程式碼不難看出,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
            }
        });
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

https是如何工作的一文中我們說過android已經幫我們預置了150多個證照,這些證照你可以在設定->安全->信任的憑據中看到(在windows中,你可以在命令列中開啟certmgr.msc來開啟證照管理器,這裡你可以看看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();
        }

    }   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41

上面的程式碼不要輕易的應用在實際工程中,除非你能容忍他的危害。為什麼這麼說呢?繼續往下看。

在上面的程式碼中,我們自行實現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();
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72

其中PUB_KEY是我們證照中的公鑰,你可以自行從自己的證照中提取。我們看到,在checkServerTrusted()方法中,我們通過證照的公鑰資訊來確認證照的真偽,如果驗證失敗,則中斷請求。當然,此處加入證照的有效期會更加的完善,實現起來比較簡單,這裡就不做說明了。

除了上面那種在checkServerTrusted()實現證照驗證的方式之外,我們也可以利用retrofit中CertificatePinner來實現證照鎖定,同樣也能達到我們的目的。

客戶端內建證照

如果我們使用的是自簽名證照,那麼客戶端中的retrofit該如何進行設定呢?關鍵還是我們上文提到的TrustManager。在retrofit中使用自簽名證照大致要經過以下幾步:

  1. 將證照新增到工程中
  2. 自定義信任管理器TrustManager
  3. 用自定義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;
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37

用自定義TrustManager代替系統預設的信任管理器

   private void onHttpCertficates(OkHttpClient.Builder builder) {
        int[] certficates = new int[]{R.raw.media};
        builder.socketFactory(getSSLSocketFactory(AppContext.context(), certficates));
    }
  • 1
  • 2
  • 3
  • 4
  • 1
  • 2
  • 3
  • 4

這樣我們就可以的客戶端就可以使用自簽名的證照了。其實不難發現,使用非android認證證照頒發機構頒發的證照的關鍵在於:修改android中SSLContext自帶的TrustManager以便能讓我們的簽名通過驗證。


暫告一段落

本文中簡單首先介紹了證照鎖定的使用、原理及優缺點,接著對客戶端使用自定義證照中的一些點做了介紹,希望能幫助各位打造安全的安卓客戶端。

另外,大多數情況下,我建議使用證照鎖定來提高安全性。關於雙向證照驗證,後續有時間再補充。

轉自:http://blog.csdn.net/dd864140130/article/details/52625666

相關文章