HTTPS 原理淺析及其在 Android 中的使用

曹豐斌發表於2017-06-06

本文首先分析HTTP協議在安全性上的不足,進而闡述HTTPS實現安全通訊的關鍵技術點和原理。然後通過抓包分析HTTPS協議的握手以及通訊過程。最後總結一下自己在開發過程中遇到的HTTPS相關的問題,並給出當前專案中對HTTPS問題的系統解決方案,以供總結和分享。

1.HTTP協議的不足

HTTP1.x在傳輸資料時,所有傳輸的內容都是明文,客戶端和伺服器端都無法驗證對方的身份,存在的問題如下:

  • 通訊使用明文(不加密),內容可能會被竊聽;
  • 不驗證通訊方的身份,有可能遭遇偽裝;
  • 無法證明報文的完整性,所以有可能已遭篡改;

其實這些問題不僅在HTTP上出現,其他未加密的協議中也會存在這類問題。

(1) 通訊使用明文可能會被竊聽

按TCP/IP協議族的工作機制,網際網路上的任何角落都存在通訊內容被竊聽的風險。而HTTP協議本身不具備加密的功能,所傳輸的都是明文。即使已經經過過加密處理的通訊,也會被窺視到通訊內容,這點和未加密的通訊是相同的。只是說如果通訊經過加密,就有可能讓人無法破解報文資訊的含義,但加密處理後的報文資訊本身還是會被看到的。

(2) 不驗證通訊方的身份可能遭遇偽裝

在HTTP協議通訊時,由於不存在確認通訊方的處理步驟,因此任何人都可以發起請求。另外,伺服器只要接收到請求,不管對方是誰都會返回一個響應。因此不確認通訊方,存在以下隱患:

  • 無法確定請求傳送至目標的Web伺服器是否是按真實意圖返回響應的那臺伺服器。有可能是已偽裝的 Web 伺服器;
  • 無法確定響應返回到的客戶端是否是按真實意圖接收響應的那個客戶端。有可能是已偽裝的客戶端;
  • 無法確定正在通訊的對方是否具備訪問許可權。因為某些Web伺服器上儲存著重要的資訊,只想發給特定使用者通訊的許可權;
  • 無法判定請求是來自何方、出自誰手;
  • 即使是無意義的請求也會照單全收,無法阻止海量請求下的DoS攻擊;

(3) 無法證明報文完整性,可能已遭篡改

所謂完整性是指資訊的準確度。若無法證明其完整性,通常也就意味著無法判斷資訊是否準確。HTTP協議無法證明通訊的報文完整性,在請求或響應送出之後直到對方接收之前的這段時間內,即使請求或響應的內容遭到篡改,也沒有辦法獲悉。

比如,從某個Web網站下載內容,是無法確定客戶端下載的檔案和伺服器上存放的檔案是否前後一致的。檔案內容在傳輸途中可能已經被篡改為其他的內容。即使內容真的已改變,作為接收方的客戶端也是覺察不到的。像這樣,請求或響應在傳輸途中,遭攻擊者攔截並篡改內容的攻擊稱為中間人攻擊(Man-in-the-Middle attack,MITM)。

(4) 安全的HTTP版本應該具備的幾個特徵

由於上述的幾個問題,需要一種能夠提供如下功能的HTTP安全技術:

(1) 伺服器認證(客戶端知道它們是在與真正的而不是偽造的伺服器通話);

(2) 客戶端認證(伺服器知道它們是在與真正的而不是偽造的客戶端通話);

(3) 完整性(客戶端和伺服器的資料不會被修改);

(4) 加密(客戶端和伺服器的對話是私密的,無需擔心被竊聽);

(5) 效率(一個執行的足夠快的演算法,以便低端的客戶端和伺服器使用);

(6) 普適性(基本上所有的客戶端和伺服器都支援這些協議);

2.HTTPS的關鍵技術

在這樣的需求背景下,HTTPS技術誕生了。HTTPS協議的主要功能基本都依賴於TLS/SSL協議,提供了身份驗證、資訊加密和完整性校驗的功能,可以解決HTTP存在的安全問題。本節就重點探討一下HTTPS協議的幾個關鍵技術點。

(1) 加密技術

加密演算法一般分為兩種:

對稱加密:加密與解密的金鑰相同。以DES演算法為代表;

非對稱加密:加密與解密的金鑰不相同。以RSA演算法為代表;

對稱加密強度非常高,一般破解不了,但存在一個很大的問題就是無法安全地生成和保管金鑰,假如客戶端和伺服器之間每次會話都使用固定的、相同的金鑰加密和解密,肯定存在很大的安全隱患。

在非對稱金鑰交換演算法出現以前,對稱加密一個很大的問題就是不知道如何安全生成和保管金鑰。非對稱金鑰交換過程主要就是為了解決這個問題,使金鑰的生成和使用更加安全。但同時也是HTTPS效能和速度嚴重降低的“罪魁禍首”。

HTTPS採用對稱加密和非對稱加密兩者並用的混合加密機制,在交換金鑰環節使用非對稱加密方式,之後的建立通訊交換報文階段則使用對稱加密方式。

(2) 身份驗證–證明公開金鑰正確性的證照

非對稱加密最大的一個問題,就是無法證明公鑰本身就是貨真價實的公鑰。比如,正準備和某臺伺服器建立公開金鑰加密方式下的通訊時,如何證明收到的公開金鑰就是原本預想的那臺伺服器發行的公開金鑰。或許在公開金鑰傳輸途中,真正的公開金鑰已經被攻擊者替換掉了。

如果不驗證公鑰的可靠性,至少會存在如下的兩個問題:中間人攻擊和資訊抵賴。

為了解決上述問題,可以使用由數字證照認證機構(CA,Certificate Authority)和其相關機關頒發的公開金鑰證照。

CA使用具體的流程如下:

(1) 伺服器的運營人員向數字證照認證機構(CA)提出公開金鑰的申請;

(2) CA通過線上、線下等多種手段驗證申請者提供資訊的真實性,如組織是否存在、企業是否合法,是否擁有域名的所有權等;

(3) 如果資訊稽核通過,CA會對已申請的公開金鑰做數字簽名,然後分配這個已簽名的公開金鑰,並將該公開金鑰放入公鑰證照後繫結在一起。 證照包含以下資訊:申請者公鑰、申請者的組織資訊和個人資訊、簽發機構CA的資訊、有效時間、證照序列號等資訊的明文,同時包含一個簽名; 簽名的產生演算法:首先,使用雜湊函式計算公開的明文資訊的資訊摘要,然後,採用CA的私鑰對資訊摘要進行加密,密文即簽名;

(4) 客戶端在HTTPS握手階段向伺服器發出請求,要求伺服器返回證照檔案;

(5) 客戶端讀取證照中的相關的明文資訊,採用相同的雜湊函式計算得到資訊摘要,然後,利用對應CA的公鑰解密簽名資料,對比證照的資訊摘要,如果一致,則可以確認證照的合法性,即公鑰合法;

(6) 客戶端然後驗證證照相關的域名資訊、有效時間等資訊;

(7) 客戶端會內建信任CA的證照資訊(包含公鑰),如果CA不被信任,則找不到對應CA的證照,證照也會被判定非法。

在這個過程注意幾點:

(1) 申請證照不需要提供私鑰,確保私鑰永遠只能被伺服器掌握;

(2) 證照的合法性仍然依賴於非對稱加密演算法,證照主要是增加了伺服器資訊以及簽名;

(3) 內建CA對應的證照稱為根證照;頒發者和使用者相同,自己為自己簽名,叫自簽名證照;

(4) 證照=公鑰+申請者與頒發者資訊+簽名;

3.HTTPS協議原理

(1) HTTPS的歷史

HTTPS協議歷史簡介:

  • (1) SSL協議的第一個版本由Netscape公司開發,但這個版本從未釋出過;
  • (2) SSL協議第二版於1994年11月釋出。第一次部署是在Netscape Navigator1.1瀏覽器上,發行於1995年3月;
  • (3) SSL 3於1995年年底釋出,雖然名稱與早先的協議版本相同,但SSL3是完全重新設計的協議,該設計一直沿用到今天。
  • (4) TLS 1.0於1999年1月問世,與SSL 3相比,版本修改並不大;
  • (5) 2006年4月,下一個版本TLS 1.1才問世,僅僅修復了一些關鍵的安全問題;
  • (6) 2008年8月,TLS1.2釋出。該版本新增了對已驗證加密的支援,並且基本上刪除了協議說明中所有硬編碼的安全基元,使協議完全彈性化;

(2) 協議實現

巨集觀上,TLS以記錄協議(record protocol)實現。記錄協議負責在傳輸連線上交換所有的底層訊息,並可以配置加密。每一條TLS記錄以一個短標頭起始。標頭包含記錄內容的型別(或子協議)、協議版本和長度。訊息資料緊跟在標頭之後,如下圖所示:

TLS的主規格說明書定義了四個核心子協議:

  • 握手協議(handshake protocol);
  • 金鑰規格變更協議(change cipher spec protocol);
  • 應用資料協議(application data protocol);
  • 警報協議(alert protocol);

(3) 握手協議

握手是TLS協議中最精密複雜的部分。在這個過程中,通訊雙方協商連線引數,並且完成身份驗證。根據使用的功能的不同,整個過程通常需要交換6~10條訊息。根據配置和支援的協議擴充套件的不同,交換過程可能有許多變種。在使用中經常可以觀察到以下三種流程:

  • (1) 完整的握手,對伺服器進行身份驗證(單向驗證,最常見);
  • (2) 對客戶端和伺服器都進行身份驗證的握手(雙向驗證);
  • (3) 恢復之前的會話採用的簡短握手;

(4) 單向驗證的握手流程

本節以QQ郵箱的登入過程為例,通過抓包來對單向驗證的握手流程進行分析。單向驗證的一次完整的握手流程如下所示:

主要分為四個步驟:

  • (1) 交換各自支援的功能,對需要的連線引數達成一致;
  • (2) 驗證出示的證照,或使用其他方式進行身份驗證;
  • (3) 對將用於保護會話的共享主金鑰達成一致;
  • (4) 驗證握手訊息是否被第三方團體修改;

下面對這一過程進行詳細的分析。

1.ClientHello

在握手流程中,ClientHello是第一條訊息。這條訊息將客戶端的功能和首選項傳送給伺服器。包含客戶端支援的SSL的指定版本、加密元件(Cipher Suite)列表(所使用的加密演算法及金鑰長度等)。

2.ServerHello

ServerHello訊息將伺服器選擇的連線引數傳送回客戶端。這個訊息的結構與ClientHello類似,只是每個欄位只包含一個選項。伺服器的加密元件內容以及壓縮方法等都是從接收到的客戶端加密元件內篩選出來的。

3.Certificate

之後伺服器傳送Certificate報文,報文中包含公開金鑰證照,伺服器必須保證它傳送的證照與選擇的演算法套件一致。不過Certificate訊息是可選的,因為並非所有套件都使用身份驗證,也並非所有身份驗證方法都需要證照。

4.ServerKeyExchange

ServerKeyExchange訊息的目的是攜帶金鑰交換的額外資料。訊息內容對於不同的協商演算法套件都會存在差異。在某些場景中,伺服器不需要傳送任何內容,在這些場景中就不需要傳送ServerKeyExchange訊息。

5.ServerHelloDone

ServerHelloDone訊息表明伺服器已經將所有預計的握手訊息傳送完畢。在此之後,伺服器會等待客戶端傳送訊息。

6.ClientKeyExchange

ClientKeyExchange訊息攜帶客戶端為金鑰交換提供的所有資訊。這個訊息受協商的密碼套件的影響,內容隨著不同的協商密碼套件而不同。

7.ChangeCipherSpec

ChangeCipherSpec訊息表明傳送端已取得用以生成連線引數的足夠資訊,已生成加密金鑰,並且將切換到加密模式。客戶端和伺服器在條件成熟時都會傳送這個訊息。注意:ChangeCipherSpec不屬於握手訊息,它是另一種協議,只有一條訊息,作為它的子協議進行實現。

8.Finished

Finished訊息意味著握手已經完成。訊息內容將加密,以便雙方可以安全地交換驗證整個握手完整性所需的資料。客戶端和伺服器在條件成熟時都會傳送這個訊息。

(5) 雙向驗證的握手流程

在一些對安全性要求更高的場景下,可能會出現雙向驗證的需求。完整的雙向驗證流程如下:

可以看到,同單向驗證流程相比,雙向驗證多瞭如下兩條訊息:CertificateRequest與CertificateVerify,其餘流程大致相同。

1.Certificate Request

Certificate Request是TLS規定的一個可選功能,用於伺服器認證客戶端的身份。通過伺服器要求客戶端傳送一個證照實現,伺服器應該在ServerKeyExchange之後立即傳送CertificateRequest訊息。

訊息結構如下:

enum { 
    rsa_sign(1), dss_sign(2), rsa_fixed_dh(3),dss_fixed_dh(4), 
    rsa_ephemeral_dh_RESERVED(5),dss_ephemeral_dh_RESERVED(6), 
    fortezza_dms_RESERVED(20), 
    ecdsa_sign(64), rsa_fixed_ecdh(65), 
    ecdsa_fixed_ecdh(66),  
    (255) 
} ClientCertificateType; 

opaque DistinguishedName<1..2^16-1>;struct { 
    ClientCertificateType certificate_types<1..2^8-1>; 
    SignatureAndHashAlgorithm 
      supported_signature_algorithms<2^16-1>; 
    DistinguishedName certificate_authorities<0..2^16-1>; 
} CertificateRequest;

可以選擇傳送一份自己接受的證照頒發機構列表,這些機構都用其可分辨名稱來表示.

2.CertificateVerify

當需要做客戶端認證時,客戶端傳送CertificateVerify訊息,來證明自己確實擁有客戶端證照的私鑰。這條訊息僅僅在客戶端證照有簽名能力的情況下傳送。CertificateVerify必須緊跟在ClientKeyExchange之後。訊息結構如下:

struct {  
Signature handshake_messages_signature;  
} CertificateVerify;

(6) 應用資料協議(application data protocol)

應用資料協議攜帶著應用訊息,只以TLS的角度考慮的話,這些就是資料緩衝區。記錄層使用當前連線安全引數對這些訊息進行打包、碎片整理和加密。如下圖所示,可以看到傳輸的資料已經是經過加密之後的了。

(7) 警報協議(alert protocol)

警報的目的是以簡單的通知機制告知對端通訊出現異常狀況。它通常會攜帶close_notify異常,在連線關閉時使用,報告錯誤。警報非常簡單,只有兩個欄位:

struct {  
    AlertLevel level;  
    AlertDescription description;  
} Alert;
  • AlertLevel欄位:表示警報的嚴重程度;
  • AlertDescription:直接表示警報程式碼;

4.在Android中使用HTTPS的常見問題

(1) 伺服器證照驗證錯誤

這是最常見的一種問題,通常會丟擲如下型別的異常:

出現此類錯誤通常可能由以下的三種原因導致:

  • (1) 頒發伺服器證照的CA未知;
  • (2) 伺服器證照不是由CA簽署的,而是自簽署(比較常見);
  • (3) 伺服器配置缺少中間 CA;

當伺服器的CA不被系統信任時,就會發生 SSLHandshakeException。可能是購買的CA證照比較新,Android系統還未信任,也可能是伺服器使用的是自簽名證照(這個在測試階段經常遇到)。

解決此類問題常見的做法是:指定HttpsURLConnection信任特定的CA集合。在本文的第5部分程式碼實現模組,會詳細的講解如何讓Android應用信任自簽名證照集合或者跳過證照校驗的環節。

(2) 域名驗證失敗

SSL連線有兩個關鍵環節。首先是驗證證照是否來自值得信任的來源,其次確保正在通訊的伺服器提供正確的證照。如果沒有提供,通常會看到類似於下面的錯誤:

出現此類問題的原因通常是由於伺服器證照中配置的域名和客戶端請求的域名不一致所導致的。

有兩種解決方案:

(1) 重新生成伺服器的證照,用真實的域名資訊;

(2) 自定義HostnameVerifier,在握手期間,如果URL的主機名和伺服器的標識主機名不匹配,則驗證機制可以回撥此介面的實現程式來確定是否應該允許此連線。可以通過自定義HostnameVerifier實現一個白名單的功能。

程式碼如下:

HostnameVerifier DO_NOT_VERIFY = new HostnameVerifier() { 
  @Override 
  public boolean verify(String hostname, SSLSession session) { 
    // 設定接受的域名集合 
    if (hostname.equals(...))  { 
         return true; 
    } 
  } 
}; 

HttpsURLConnection.setDefaultHostnameVerifier(DO_NOT_VERIFY);

(3) 客戶端證照驗證

SSL支援服務端通過驗證客戶端的證照來確認客戶端的身份。這種技術與TrustManager的特性相似。本文將在第5部分程式碼實現模組,講解如何讓Android應用支援客戶端證照驗證的方式。

(4) Android上TLS版本相容問題

之前在介面聯調的過程中,測試那邊反饋過一個問題是在Android 4.4以下的系統出現HTTPS請求不成功而在4.4以上的系統上卻正常的問題。相應的錯誤如下:

03-09 09:21:38.427: W/System.err(2496): javax.net.ssl.SSLHandshakeException: javax.net.ssl.SSLProtocolException: SSL handshake aborted: ssl=0xb7fa0620: Failure in SSL library, usually a protocol error 

03-09 09:21:38.427: W/System.err(2496): error:14077410:SSL routines:SSL23_GET_SERVER_HELLO:sslv3 alert handshake failure (external/openssl/ssl/s23_clnt.c:741 0xa90e6990:0x00000000)

按照官方文件的描述,Android系統對SSL協議的版本支援如下:

也就是說,按官方的文件顯示,在API 16+以上,TLS1.1和TLS1.2是預設開啟的。但是實際上在API 20+以上才預設開啟,4.4以下的版本是無法使用TLS1.1和TLS 1.2的,這也是Android系統的一個bug。

參照stackoverflow上的一些方式,比較好的一種解決方案如下:

SSLSocketFactory noSSLv3Factory; 
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) { 
    noSSLv3Factory = new TLSSocketFactory(mSSLContext.getSSLSocket().getSocketFactory()); 
} else { 
    noSSLv3Factory = mSSLContext.getSSLSocket().getSocketFactory(); 
}

對於4.4以下的系統,使用自定義的TLSSocketFactory,開啟對TLS1.1和TLS1.2的支援,核心程式碼:

public class TLSSocketFactory extends SSLSocketFactory { 

    private SSLSocketFactory internalSSLSocketFactory; 

    public TLSSocketFactory() throws KeyManagementException, NoSuchAlgorithmException { 
        SSLContext context = SSLContext.getInstance("TLS"); 
        context.init(null, null, null); 
        internalSSLSocketFactory = context.getSocketFactory(); 
    } 

    public TLSSocketFactory(SSLSocketFactory delegate) throws KeyManagementException, NoSuchAlgorithmException { 
        internalSSLSocketFactory = delegate; 
    } 

    ...... 

    @Override 
    public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException { 
        return enableTLSOnSocket(internalSSLSocketFactory.createSocket(address, port, localAddress, localPort)); 
    } 

    // 開啟對TLS1.1和TLS1.2的支援 
    private Socket enableTLSOnSocket(Socket socket) { 
        if(socket != null && (socket instanceof SSLSocket)) { 
            ((SSLSocket)socket).setEnabledProtocols(new String[] {"TLSv1.1", "TLSv1.2"}); 
        } 
        return socket; 
    } 
}

5.程式碼實現

本部分主要基於第四部分提出的Android應用中使用HTTPS遇到的一些常見的問題,給出一個比較系統的解決方案。

(1) 整體結構

不管是使用自簽名證照,還是採取客戶端身份驗證,核心都是建立一個自己的KeyStore,然後使用這個KeyStore建立一個自定義的SSLContext。整體類圖如下:

類圖中的MySSLContext可以應用在HttpURLConnection的方式與服務端連線的過程中:

if (JarConfig.__self_signed_https) { 
    SSLContextByTrustAll mSSLContextByTrustAll = new SSLContextByTrustAll(); 
    MySSLContext mSSLContext = new MySSLContext(mSSLContextByTrustAll); 
   SSLSocketFactory noSSLv3Factory; 
   if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) { 
        noSSLv3Factory = new TLSSocketFactory(mSSLContext.getSSLSocket().getSocketFactory()); 
    } else { 
        noSSLv3Factory = mSSLContext.getSSLSocket().getSocketFactory(); 
    } 

    httpsURLConnection.setSSLSocketFactory(noSSLv3Factory); 
    httpsURLConnection.setHostnameVerifier(MY_DOMAIN_VERIFY); 
}else { 
    httpsURLConnection.setSSLSocketFactory((SSLSocketFactory) SSLSocketFactory.getDefault()); 
    httpsURLConnection.setHostnameVerifier(DO_NOT_VERIFY); 
}

核心是通過httpsURLConnection.setSSLSocketFactory使用自定義的校驗邏輯。整體設計上使用策略模式決定採用哪種驗證機制:

  • makeContextWithCilentAndServer 單向驗證方式(自定義信任的證照集合)
  • makeContextWithServer 雙向驗證方式(自定義信任的證照集合,並使用客戶端證照)
  • makeContextToTrustAll (信任所有的CA證照,不安全,僅供測試階段使用)

(2) 單向驗證並自定義信任的證照集合

在App中,把服務端證照放到資原始檔下(通常是asset目錄下,因為證照對於每一個使用者來說都是相同的,並且也不會經常發生改變),但是也可以放在裝置的外部儲存上。

public class SSLContextWithServer implements GetSSLSocket { 

    // 在這裡進行伺服器正式的名稱的配置 
    private String[] serverCertificateNames = {"serverCertificateNames1" ,"serverCertificateNames2"}; 

    @Override 
    public SSLContext getSSLSocket() { 
        String[] caCertString = new String[serverCertificateNames.length]; 
        for(int i = 0 ; i < serverCertificateNames.length ; i++) { 
            try { 
                caCertString[i] = readCaCert(serverCertificateNames[i]); 
            } catch(Exception e) { 

            } 
        } 
        SSLContext mSSLContext = null; 
        try { 
            mSSLContext = SSLContextFactory.getInstance().makeContextWithServer(caCertString); 
        } catch(Exception e) { 

        } 
        return mSSLContext; 
    }

serverCertificateNames中定義了App所信任的證照名稱(這些證照檔案必須要放在指定的檔案路徑下,並其要保證名稱相同),而後就可以載入服務端證照鏈到keystore,通過獲取到的可信任並帶有服務端證照的keystore,就可以用它來初始化自定義的SSLContext了:

@Override 
    public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { 
        try { 
            originalX509TrustManager.checkServerTrusted(chain, authType); 
        } catch(CertificateException originalException) { 
            try { 
                X509Certificate[] reorderedChain = reorderCertificateChain(chain); 
                CertPathValidator validator = CertPathValidator.getInstance("PKIX"); 
                CertificateFactory factory = CertificateFactory.getInstance("X509"); 
                CertPath certPath = factory.generateCertPath(Arrays.asList(reorderedChain)); 
                PKIXParameters params = new PKIXParameters(trustStore); 
                params.setRevocationEnabled(false); 
                validator.validate(certPath, params); 
            } catch(Exception ex) { 
                throw originalException; 
            } 
        } 
    }

(3) 跳過證照校驗過程

和上面的過程類似,只不過這裡提供的TrustManager不需要提供信任的證照集合,預設接受任意客戶端證照即可:

public class AcceptAllTrustManager implements X509TrustManager { 

    @Override 
    public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { 
        //do nothing,接受任意客戶端證照 
    } 

    @Override 
    public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { 
        //do nothing,接受任意服務端證照 
    } 

    @Override 
    public X509Certificate[] getAcceptedIssuers() { 
        return null; 
    }

而後構造相應的SSLContext:

public SSLContext makeContextToTrustAll() throws Exception { 
        AcceptAllTrustManager tm = new AcceptAllTrustManager(); 
        SSLContext sslContext = SSLContext.getInstance("TLS"); 
        sslContext.init(null, new TrustManager[] { tm }, null); 

        return sslContext; 
}

相關文章