認真理解iOS開發中HTTPS協議的用法

原創文章首發本人博客: blog.cocosdever.com/2019/08/01/…算法

文檔更新說明

  • 最後更新 2019年08月05日
  • 首次更新 2019年08月01日

前言

  網上有不少相似文章, 但我發現其中多少有一些致命錯誤和誤解, 本文是我通過測試,翻看權威源碼以後寫出的, 儘可能把程序在作什麼個寫明白.segmentfault

本文的主角就是下面這個方法, 他屬於NSURLSessionDelegate協議的, 至於古老版本的HTTPS相關接口就不說了.(NSURLSessionTaskDelegate有一個相似的屬於task-level, 同理).   數組

- (void)URLSession:(NSURLSession *)session 
didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge 
completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential *credential))completionHandler
複製代碼

  經過實現這個方法, 咱們能夠實現下面的技術需求:安全

  1. 驗證服務器證書是否在系統信任列表中
  2. 實現服務器雙向認證請求
  3. 驗證自制HTTPS證書

要理解這三種需求的實現, 首先要理解HTTPS和HTTP的不一樣之處, 即TLS協議. HTTPS協議能夠簡單理解爲HTTP+TLS , TLS協議全稱Transport Layer Security, 它又分爲TLS Record和TLS Handshake兩部分, 咱們關心的就是握手(Handshake)部分. 詳細的協議內容網上有不少文章, 這裏推薦一下這篇SSL/TLS原理詳解.服務器

TLS Handshake

  TLS Handshake負責完成一系列密鑰交換, 目的就是爲了讓客戶端和服務器可以使用同一把私鑰對傳輸的內容進行對稱加密, 從而確保兩端數據的安全傳輸. 理解整個握手的過程, 有助於咱們理解iOS中HTTPS協議的使用. 下面我就簡單說一下握手的過程, 詳細過程能夠看到上面提到的文章.session

TLS Handshake:app

  1. 客戶端生成隨機數Client random, 聲明支持的加密方式, 發送給服務端. (ClientHello)
  2. 服務端確認加密方式, 生成隨機數Server random, 給出服務端證書, 發送給客戶端. (SeverHello, SeverHello Done)
  3. 若是服務端要求雙向認證,則客戶端須要提供客戶端證書給服務端(Client Key Exchange); 接着客戶端驗證服務端證書是否合法, 生成隨機數Pre-Master, 並使用服務端證書中的公鑰進行加密, 發送給服務端. (Certificate Verify)
  4. 服務端使用本身的證書私鑰, 解密客戶端發送來的加密信心, 獲得Per-Master
  5. 客戶端和服務端此時擁有三個相同的隨機數, 按照相同算法生成對話私鑰, 彼此互相使用對話私鑰加密Finish信息互相確認私鑰正確性, 握手完成.

理解didReceiveChallenge方法

  理解TLS握手流程, 就能夠知道上面提到的三點技術需求的開發時機.didReceiveChallenge方法提供了一個參數(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential *credential))completionHandler, 它是一個Block, 主要是讓開發者向URLSession提供受權信息, 一共有三種:框架

typedef NS_ENUM(NSInteger, NSURLSessionAuthChallengeDisposition) {
    // 使用指定證書
    NSURLSessionAuthChallengeUseCredential = 0,
    
    // 系統默認處理挑戰的方式, 沒有實現代理方法的時候就是這種處理方式
    NSURLSessionAuthChallengePerformDefaultHandling = 1,
    
    // TLS握手將會被取消
    NSURLSessionAuthChallengeCancelAuthenticationChallenge = 2,
    
    // 拒絕本次保護空間的認證挑戰, 下一個保護空間會從新認證(實際測試發現效果和NSURLSessionAuthChallengePerformDefaultHandling相似), 
    // 要取消請直接使用NSURLSessionAuthChallengeCancelAuthenticationChallenge
    NSURLSessionAuthChallengeRejectProtectionSpace = 3,
    
} NS_ENUM_AVAILABLE(NSURLSESSION_AVAILABLE, 7_0);
複製代碼

接着再看另外一個參數NSURLAuthenticationChallenge *challenge, 它包含了本次認證挑戰的基本信息, 其中咱們關心的是服務端的保護空間(protectionSpace), 裏面有服務端域名, 端口, TLS認證方法(authenticationMethod)等信息. 有了這些信息, 開發者才能知道當前進行的TLS Handshake須要哪些認證方式. 下面我會舉一個涵蓋99%場景的認證例子(NSURLAuthenticationMethodServerTrust), 也就是認證服務器證書, 來幫助你們理解.dom

處理權威機構簽發的證書

  對於權威機構簽發的證書, 這類證書上面會聲明本身是由哪個CA機構(或CA的子機構)簽發, 而對應的CA機構也有本身的CA證書, 在手機出廠以前就被安裝進系統裏了, 這樣對於權威機構簽發的服務器證書, 只要從系統裏找一下服務器證書對應的CA證書, 拿CA證書的公鑰解密一下服務器證書的簽名, 解密出的Hash是否是和服務器攜帶的數據部分運算出的Hash一致, 便可證實服務器證書是合法的. 若是不實現didReceiveChallenge這個協議方法, 系統會自動幫忙處理好. 固然有興趣也能夠本身試一試, 下面是示例代碼:ide

- (void)URLSession:(NSURLSession *)session didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential * _Nullable))completionHandler {
    
    // 查看服務器提供的認證方式
    NSLog(@"protectionSpace.authenticationMethod = %@", challenge.protectionSpace.authenticationMethod);
    if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {

	    // 判斷服務器的證書是否合法. (系統默認也會作這樣的操做)
	    SecTrustResultType result;
	    SecTrustEvaluate(challenge.protectionSpace.serverTrust, &result);
	    
	    if(result == kSecTrustResultUnspecified || result == kSecTrustResultProceed) {
	        NSLog(@"合法");
	        completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil);
	    } else {
	        NSLog(@"不合法");
	        completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil);
	    }
    }
    // 這裏只處理單向認證, 其餘狀況不考慮
    completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil);    
}
複製代碼

上面有一個地方須要特別注意, 當證書合法時, 若是返回NSURLSessionAuthChallengePerformDefaultHandling, 則表示由系統處理, 此處執行completionHandler(NSURLSessionAuthChallengeUseCredential, nil)也是能夠的, 效果和NSURLSessionAuthChallengePerformDefaultHandling同樣.

處理服務器自制證書

  這裏分兩種狀況, 一種是無視服務器證書, 一種是要求服務器證書和咱們APP內置證書相同時才認可. 無視服務器證書時, 那就是不須要任何驗證, 此時須要實現didReceiveChallenge方法. 由於系統默認是不會接受非權威機構的證書, 所以也不能返回completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil);. 比較容易混淆的地方是, 文檔並無明確說明單向認證承認服務端證書時completionHandler參數傳入什麼, 實際測試發現自制證書第一個參數須要傳入NSURLSessionAuthChallengeUseCredential, 第二個參數傳入服務端的serverTrust(AFNetworking是這樣實現的), 這部分代碼以下:

- (void)URLSession:(NSURLSession *)session didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential * _Nullable))completionHandler {
    
    // 查看服務器提供的認證方式
    NSLog(@"protectionSpace.authenticationMethod = %@", challenge.protectionSpace.authenticationMethod);
    if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
        completionHandler(NSURLSessionAuthChallengeUseCredential, challenge.protectionSpace.serverTrust);
    }
    // 這裏只處理單向認證, 其餘狀況不考慮
    completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil);    
}
複製代碼

綁定證書時, 也就是要求服務端證書的CA證書和APP內置CA證書相同(或服務端證書和APP內置證書相同), 原理也是同樣的, 先獲取服務端證書, 而後獲取本地證書, 再對比一下看看是否相同便可. 自制證書容易, 可是去哪兒弄一個自制證書的服務器來測試呢? 這裏我介紹一個小技巧, 可使用Charles這個工具, 測試訪問https://www.baidu.com, 把這個域名配置到Charles裏,

而後手機鏈接Charles代理服務器(具體抓包方法谷歌找找不少教程), 接着先把Charles的CA證書導出來,

放進APP, 這樣運行APP訪問https://www.baidu.com的時候, Charles會把百度的證書替換成Charles自制證書, 自制證書對應CA證書就是咱們導出的那個. 不過直接從Charles導出的格式是pem, 要轉成der格式:

openssl x509 -in certificate.pem -outform der -out certificate.der
複製代碼

這部分代碼以下:

- (void)URLSession:(NSURLSession *)session didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential * _Nullable))completionHandler {
    
    // 查看服務器提供的認證方式
    NSLog(@"protectionSpace.authenticationMethod = %@", challenge.protectionSpace.authenticationMethod);
    
    // 服務器的證書
    NSURLCredential *serverCredential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
    
    // 本地證書
    NSData *certificateData = [NSData dataWithContentsOfFile:[NSBundle.mainBundle pathForResource:@"certificate" ofType:@"der"]];
    
    // 系統API是支持匹配多個證書, 這裏須要用數組存放證書
    NSMutableArray *pinnedCertificates = [NSMutableArray array];
    
    // 這裏已經__bridge_transfer了, 全部權交由NSMutableArray管理, 因此不須要手動Release SecCertificateCreateWithData
    [pinnedCertificates addObject:(__bridge_transfer id)SecCertificateCreateWithData(NULL, (__bridge CFDataRef)certificateData)];
    
    // 提示一下, 這裏的C接口只是針對傳入的serverTrust對象進行可信證書集合綁定,具體看文檔
    SecTrustSetAnchorCertificates(challenge.protectionSpace.serverTrust, (__bridge CFArrayRef)pinnedCertificates);

    SecTrustResultType result;
    SecTrustEvaluate(challenge.protectionSpace.serverTrust, &result);
    
    if(result == kSecTrustResultUnspecified || result == kSecTrustResultProceed) {
        // 這裏使用了Charles的CA證書, 檢查Charles自制證書因此是合法的
        NSLog(@"合法");
        
        // 此外還能夠直接檢查本地APP內置證書是否和服務端證書所包含的證書鏈之中的一個匹配, 代碼以下
        CFIndex certificateCount = SecTrustGetCertificateCount(challenge.protectionSpace.serverTrust);
        NSMutableArray *trustChain = [NSMutableArray arrayWithCapacity:(NSUInteger)certificateCount];
        
        for (CFIndex i = 0; i < certificateCount; i++) {
            SecCertificateRef certificate = SecTrustGetCertificateAtIndex(challenge.protectionSpace.serverTrust, i);
            [trustChain addObject:(__bridge_transfer NSData *)SecCertificateCopyData(certificate)];
        }
        
        NSArray *serverCertificates =  [NSArray arrayWithArray:trustChain];
        
        // 檢查服務器證書是否包含本地證書
        if ([serverCertificates containsObject:certificateData]) {
            NSLog(@"服務器證書和本地證書相同");
            completionHandler(NSURLSessionAuthChallengeUseCredential, serverCredential);
        }else {
            completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil);
        }
    } else {
        NSLog(@"不合法");
        completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil);
    }    
}
複製代碼

運行結果

下面須要重點說明一個問題 不少文章都把服務器返回狀態碼401和TLS混在一塊兒講了, 401狀態碼的頭部信息已是在TLS握手以後, 確認雙方合法以後, 被加密傳輸的內容, 屬於HTTP協議部分了, 因此401和HTTPS權限認證不是一回事. 不過他們在iOS中均可以經過task-level的didReceiveChallenge來完成認證.

處理TLS Handshake雙向認證

  先看一下didReceiveChallenge的文檔, 說得很清楚

This method is called in two situations:

  • When a remote server asks for client certificates or Windows NT LAN Manager (NTLM) authentication, to allow your app to provide appropriate credentials
  • When a session first establishes a connection to a remote server that uses SSL or TLS, to allow your app to verify the server’s certificate chain

Note

This method handles only the NSURLAuthenticationMethodNTLM, NSURLAuthenticationMethodNegotiate, NSURLAuthenticationMethodClientCertificate, and NSURLAuthenticationMethodServerTrust authentication types. For all other authentication schemes, the session calls only the URLSession:task:didReceiveChallenge:completionHandler: method.

session-level的didReceiveChallenge方法只會在如下幾種狀況下被觸發

  1. 遠程服務器要求客戶端提供證書(雙向認證)
  2. NTLM認證(微軟提供的認證方式, 具體谷歌)
  3. SSL或TLS握手階段, 容許你驗證服務端證書鏈是否合法(上面已經介紹過) 其餘認證狀態將調用task-level的代理方法.

具體代碼和上面單向認證同樣, completionHandler第二個參數傳入服務端承認的證書便可.

總結

  這篇文章先是講述了HTTPS協議的加密原理, 而後講述了iOS開發中可以遇到的和HTTPS認證相關的場景的實現, 並給出常見認證的代碼, 並解釋了爲何要這麼作. 其中C接口的部分代碼參考AFNetworking框架, 這個框架封裝了權威證書認證, 單向自制證書認證的功能, 好像缺乏雙向認證, 不過AFSecurityPolicy卻是提供了一個開發者自行訂製認證邏輯的block, 能夠直接實現認證邏輯block並賦值給sessionDidReceiveAuthenticationChallenge便可. 其餘的有興趣的能夠自行查閱代碼, 源碼都比較簡單.

相關文章
相關標籤/搜索