用自簽名證書(self-signed certificate)終結蘋果的HTTPS

本文用swift,實現在使用自簽名證書的情況下,連接https服務器。Allow Arbitrary Loads設為NO,且無需把域名加入到NSExceptionDomains中。分別使用了:URLSession、 ASIHTTPRequest、 AFNetworking、NSURLConnection、RestKit、UIWebView。

swift版本
Apple Swift version 3.0.1 (swiftlang-800.0.58.6 clang-800.0.42.1)
Target: x86_64-apple-macosx10.9

蘋果毫無懸念地,一直在反人類。強制搞ATS這種事情,我覺得裝逼的成分多一點,和蘋果一貫的作風類似。https當然比http要安全得多,但是讓如此眾多的廠商一齊搞這件事情,太浪費人力物力了。從泄密的嚴重程度來講,http根本不算什么重要原因。好萊塢女星的艷照就不是http的原因泄露的吧?蘋果完全可以要求新上線的APP都用https,已經上線的APP則可暫緩。很多APP說不定過兩年就死掉了呢?

本來上https也不難,但是受信證書是要花錢的。老板摳門不愿意買證書是一個方面,一個內部API要額外花錢也有些不合理。所以本文就是幫你老板省錢的。

另一個好消息是,本來2016年年底是最后期限,蘋果卻在2016年12月21日發了個文,說期限拖延了,拖延多久未知。
Supporting App Transport Security
看樣子是屈服于壓力妥協了。
不過該來的總要來的,可以先把ATS搞起來,練練手。

證書

本篇不介紹證書的頒發及服務器的配置。
簡單講幾個注意點。
一、蘋果對于證書是有要求的,在這里。具體看Requirements for Connecting Using ATS一節。
請嚴格按說明配置證書。
二、對于已配好的服務器,可以用騰訊的這項服務檢測是否正常:蘋果ATS檢測
下圖是我的站的檢測結果,除了“證書被iOS9信任”這一條可以不通過以外,其他所有項必須通過檢測。

檢測結果

三、Charles不要開。Charles證書沒配好的情況下,HTTPS是連不上的;配好的情況下,程序沒寫對也能連得上。

基本思路

既然使用了https,那么安全性還是要講究的。
程序的基本思路是先將證書添加到APP項目中,用SecTrustSetAnchorCertificates方法將其設置為信任,再用SecTrustEvaluate方法驗證服務器的證書是否可信,最后生成憑證傳回服務器。
不過,如果你懶得驗證證書,上述步驟也可以簡化,我會在URLSession一節中額外闡述一下。

URLSession

作為iOS新一代的網絡連接API,URLSession能很簡單地實現自簽名證書的HTTPS。我將它寫在第一位,希望讀者能仔細閱讀,學會基本原理。這樣對于本文沒有寫到的框架也能舉一反三,實現功能。

首先,我們需要把證書文件復制到項目中,并在Copy Bundle Resources里添加證書文件。然后在程序中這樣讀取證書:

//導入客戶端證書
guard let cerPath = Bundle.main.path(forResource: "ca", ofType: "cer") else { return }
guard let data = try? Data(contentsOf: URL(fileURLWithPath: cerPath)) else { return }
guard let certificate = SecCertificateCreateWithData(nil, data as CFData) else { return }
trustedCertList = [certificate]

要實現憑證回傳,必須使用異步調用,同步調用是沒戲的。
具體的我就不寫了,大致這樣就好:

let task = session.dataTask(with: request as URLRequest, completionHandler:{(data, response, error) -> () in
    if error != nil {
            return
    }
    let newStr = String(data: data!, encoding: .utf8)
    print(newStr ?? "")
})
task.resume()

如果發送的請求是https的,URLSession會回調如下方法:(需聲明實現URLSessionTaskDelegate)

func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)

在這個方法里,我們首先要把前面取到的trustedCertList設置為信任,接著要根據本地證書來驗證服務器的證書是否可信,最后把憑證回傳。

func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
    var err: OSStatus
    var disposition: Foundation.URLSession.AuthChallengeDisposition = Foundation.URLSession.AuthChallengeDisposition.performDefaultHandling
    var trustResult: SecTrustResultType = .invalid
    var credential: URLCredential? = nil
    
    //獲取服務器的trust object
    let serverTrust: SecTrust = challenge.protectionSpace.serverTrust!
    
    //將讀取的證書設置為serverTrust的根證書
    err = SecTrustSetAnchorCertificates(serverTrust, trustedCertList)
    
    if err == noErr {
        //通過本地導入的證書來驗證服務器的證書是否可信
        err = SecTrustEvaluate(serverTrust, &trustResult)
    }
    
    if err == errSecSuccess && (trustResult == .proceed || trustResult == .unspecified) {
        //認證成功,則創建一個憑證返回給服務器
        disposition = Foundation.URLSession.AuthChallengeDisposition.useCredential
        credential = URLCredential(trust: serverTrust)
    } else {
        disposition = Foundation.URLSession.AuthChallengeDisposition.cancelAuthenticationChallenge
    }
    
    //回調憑證,傳遞給服務器
    completionHandler(disposition, credential)
    
    //如果不論安全性,不想驗證證書是否正確。那上面的代碼都不需要,直接寫下面這段即可
    //let serverTrust: SecTrust = challenge.protectionSpace.serverTrust!
    //SecTrustSetAnchorCertificates(serverTrust, trustedCertList)
    //completionHandler(.useCredential, URLCredential(trust: serverTrust))
}

最下面的三行被注釋掉的程序,是無條件確認服務器證書可信的。我不建議這樣做,上面的代碼寫寫也沒多少。

如果出現
NSURLSession/NSURLConnection HTTP load failed (kCFStreamErrorDomainSSL, -9802)

基本原因是SecTrustSetAnchorCertificates方法沒寫或沒寫對。

ASIHTTPRequest

這個庫特別古老,用的人也不多,如果不是項目中用到了這個,我是懶得寫它的。
這個庫還有很多坑。
首先,它要用到的證書是p12格式的;
其次,它底層設置信任的代碼有問題,不但有內存泄露,而且證書鏈也會出錯。

先看一下swift部分的代碼,下面是發送請求的部分,接受的部分我就不寫了:

let url = URL(string: urlString)
let request = ASIHTTPRequest.request(with: url) as! ASIHTTPRequest

//導入客戶端證書
guard let cerPath = Bundle.main.path(forResource: "ca", ofType: "p12") else { return }
guard let data = try? Data(contentsOf: URL(fileURLWithPath: cerPath)) else { return }

var identity: SecIdentity? = nil
if self.extractIdentity(outIdentity: &identity, cerData: data) {
    //設置證書,這句話是關鍵
    request.setClientCertificateIdentity(identity!)
    
    request.delegate = self
    request.startAsynchronous()
}

//如果不論安全性,不想驗證證書是否正確。那上面的代碼都不需要,直接寫下面這段即可
//request.validatesSecureCertificate = false
//request.delegate = self
//request.startAsynchronous()

func extractIdentity(outIdentity: inout SecIdentity?, cerData: Data) -> Bool {
    var securityError = errSecSuccess
    //這個字典里的value是證書密碼
    let optionsDictionary: Dictionary<String, CFString>? = [kSecImportExportPassphrase as String: "" as CFString]
    
    var items: CFArray? = nil

    securityError = SecPKCS12Import(cerData as CFData, optionsDictionary as! CFDictionary, &items)
    
    if securityError == 0 {
        let myIdentityAndTrust = items as! NSArray as! [[String:AnyObject]]
        outIdentity = myIdentityAndTrust[0][kSecImportItemIdentity as String] as! SecIdentity?
    } else {
        print(securityError)
        return false
    }
    
    return true
}

然后我們打開ASIHTTPRequest.m,來做一些修改。

// Tell CFNetwork to use a client certificate
if (clientCertificateIdentity) {
    //NSMutableDictionary *sslProperties = [NSMutableDictionary dictionaryWithCapacity:1];    //舊代碼賦值
    //鳴謝:http://bewithme.iteye.com/blog/1999031
    NSMutableDictionary *sslProperties = [[NSMutableDictionary alloc] initWithObjectsAndKeys:
                                   [NSNumber numberWithBool:YES], kCFStreamSSLAllowsExpiredCertificates,
                                   [NSNumber numberWithBool:YES], kCFStreamSSLAllowsAnyRoot,
                                   [NSNumber numberWithBool:NO],  kCFStreamSSLValidatesCertificateChain,
                                   kCFNull,kCFStreamSSLPeerName,
                                   nil];
    
    NSMutableArray *certificates = [NSMutableArray arrayWithCapacity:[clientCertificates count]+1];

    // The first object in the array is our SecIdentityRef
    [certificates addObject:(id)clientCertificateIdentity];

    // If we've added any additional certificates, add them too
    for (id cert in clientCertificates) {
        [certificates addObject:cert];
    }
    
    [sslProperties setObject:certificates forKey:(NSString *)kCFStreamSSLCertificates];
    
    CFReadStreamSetProperty((CFReadStreamRef)[self readStream], kCFStreamPropertySSLSettings, sslProperties);
    
    [sslProperties release];   //新代碼添加
}

我們需要更改兩處:一是sslProperties的賦值,二是需要釋放sslProperties。
如果不更改sslProperties的值,就會報如下錯誤。

CFNetwork SSLHandshake failed (-9807)
Error Domain=ASIHTTPRequestErrorDomain Code=1 "A connection failure occurred: SSL problem (Possible causes may include a bad/expired/self-signed certificate, clock set to wrong date)" UserInfo={NSLocalizedDescription=A connection failure occurred: SSL problem (Possible causes may include a bad/expired/self-signed certificate, clock set to wrong date), NSUnderlyingError=0x608000059470 {Error Domain=NSOSStatusErrorDomain Code=-9807 "(null)" UserInfo={_kCFStreamErrorCodeKey=-9807, _kCFStreamErrorDomainKey=3}}}

如果你在OC下仍然有內存泄露,那么extractIdentity方法的寫法可以參考一下蘋果的這份官方文檔

最后還有一個問題,extractIdentity這個方法,每次調用的時候都吃CPU。這個暫時沒有找到解決方案,請依據自己APP的CPU使用情況來權衡是否需要驗證證書。不驗證證書的方法,代碼里也有。URLSession等方法就不會每次都驗證證書,所以沒有這個問題。

AFNetworking

AFNetworking是對NSURLSession的封裝,畢竟是知名庫,對自簽名證書很友好。幾句話就能簡單搞定。

guard let cerPath = Bundle.main.path(forResource: "ca", ofType: "cer") else { return }
guard let data = try? Data(contentsOf: URL(fileURLWithPath: cerPath)) else { return }
var certSet: Set<Data> = []
certSet.insert(data)

let manager = AFHTTPSessionManager(baseURL: URL(string: urlString))
manager.responseSerializer = AFHTTPResponseSerializer()
//pinningMode設置為證書形式
manager.securityPolicy = AFSecurityPolicy.init(pinningMode: .certificate, withPinnedCertificates: certSet)
//allowInvalidCertificates必須設為true
manager.securityPolicy.allowInvalidCertificates = true
manager.securityPolicy.validatesDomainName = true

manager.get(urlString, parameters: nil,
            progress: {(pro: Progress) -> () in
},
            success: {(dataTask: URLSessionDataTask?, responseData: Any) -> () in
                print(String(data: responseData as! Data, encoding: .utf8)!)
},
            failure: {(dataTask: URLSessionDataTask?, error: Error) -> () in
                print(error)
})

參考AFNetworking的源代碼,在URLSession的回調中,調用了AFSecurityPolicy的evaluateServerTrust方法。在這個方法里,要過兩次AFServerTrustIsValid,以驗證證書。第一次代碼是這樣的:

if (self.SSLPinningMode == AFSSLPinningModeNone) {
    return self.allowInvalidCertificates || AFServerTrustIsValid(serverTrust);
} else if (!AFServerTrustIsValid(serverTrust) && !self.allowInvalidCertificates) {
    return NO;
}

為了不讓方法返回NO,我們必須把allowInvalidCertificates設置為true。在后面的代碼中,執行過SecTrustSetAnchorCertificates了之后,AFServerTrustIsValid就會返回YES了。

NSURLConnection

這個東西將是明日黃花了,以后都應該用URLSession的。
它的寫法與URLSession差不多,只在判斷證書是否正確的地方有些修改。

func connection(_ connection: NSURLConnection, willSendRequestFor challenge: URLAuthenticationChallenge) {
    var trustResult: SecTrustResultType = .invalid
    
    let serverTrust: SecTrust = challenge.protectionSpace.serverTrust!
    var err: OSStatus = SecTrustSetAnchorCertificates(serverTrust, trustedCertList)
    
    if err == noErr {
        //通過本地導入的證書來驗證服務器的證書是否可信
        err = SecTrustEvaluate(serverTrust, &trustResult)
    }
    
    if err == errSecSuccess && (trustResult == .proceed || trustResult == .unspecified) {
        //認證成功,則創建一個憑證返回給服務器
        challenge.sender?.use(URLCredential(trust: serverTrust), for: challenge)
        challenge.sender?.continueWithoutCredential(for: challenge)
    } else {
        challenge.sender?.cancel(challenge)
    }
    
    //如果不論安全性,不想驗證證書是否正確。那上面的代碼都不需要,直接寫下面這段即可
    //let serverTrust: SecTrust = challenge.protectionSpace.serverTrust!
    //SecTrustSetAnchorCertificates(serverTrust, trustedCertList)
    //challenge.sender?.use(URLCredential(trust: serverTrust), for: challenge)
    //challenge.sender?.continueWithoutCredential(for: challenge)
}

RestKit

又一個古老久遠不好用的框架。我也是蠻佩服人人網當時的架構師的,選的框架都是奇葩。
這個破爛框架一樣需要修改底層代碼,不改就會報如下錯誤:

Error Domain=NSURLErrorDomain Code=-1012 "(null)"

關鍵的改動是RestKit/Network/AFNetworking/AFRKURLConnectionOperation.m的willSendRequestForAuthenticationChallenge方法
在case AFRKSSLPinningModeCertificate處,這里的驗證是有問題的,原代碼如下:

case AFRKSSLPinningModeCertificate: {
    NSAssert([[self.class pinnedCertificates] count] > 0, @"AFRKSSLPinningModeCertificate needs at least one certificate file in the application bundle");
    for (id serverCertificateData in trustChain) {
        if ([[self.class pinnedCertificates] containsObject:serverCertificateData]) {
            NSURLCredential *credential = [NSURLCredential credentialForTrust:serverTrust];
            [[challenge sender] useCredential:credential forAuthenticationChallenge:challenge];
            return;
        }
    }
    
    NSLog(@"Error: Unknown Certificate during Pinning operation");
    [[challenge sender] cancelAuthenticationChallenge:challenge];
    break;
}

改過以后的代碼如下:

case AFRKSSLPinningModeCertificate: {
    NSAssert([[self.class pinnedCertificates] count] > 0, @"AFRKSSLPinningModeCertificate needs at least one certificate file in the application bundle");
    NSMutableArray *pinnedCertificates = [NSMutableArray array];
    for (NSData *certificateData in [self.class pinnedCertificates]) {
        [pinnedCertificates addObject:(__bridge_transfer id)SecCertificateCreateWithData(NULL, (__bridge CFDataRef)certificateData)];
    }
    SecTrustSetAnchorCertificates(serverTrust, (__bridge CFArrayRef)pinnedCertificates);
    
    if (AFServerTrustIsValid(serverTrust)) {
        NSArray *serverCertificates =  AFCertificateTrustChainForServerTrust(serverTrust);
        
        for (NSData *trustChainCertificate in [serverCertificates reverseObjectEnumerator]) {
            if ([[self.class pinnedCertificates] containsObject:trustChainCertificate]) {
                NSURLCredential *credential = [NSURLCredential credentialForTrust:serverTrust];
                [[challenge sender] useCredential:credential forAuthenticationChallenge:challenge];
                return;
            }
        }
    }
    
    //這段代碼完全錯誤,for里面的if語句不可能為true
    //for (id serverCertificateData in trustChain) {
    //    if ([[self.class pinnedCertificates] containsObject:serverCertificateData]) {
    //        NSURLCredential *credential = [NSURLCredential credentialForTrust:serverTrust];
    //        [[challenge sender] useCredential:credential forAuthenticationChallenge:challenge];
    //        return;
    //    }
    //}

    NSLog(@"Error: Unknown Certificate during Pinning operation");
    [[challenge sender] cancelAuthenticationChallenge:challenge];
    break;
}

這里關鍵一句是SecTrustSetAnchorCertificates。這句話將pinnedCertificates里面的證書設置為信任(pinnedCertificates里面的證書是在初始化對象的時候從資源文件里取的*.cer文件)。原來的代碼沒有這句話,所以if ([[self.class pinnedCertificates] containsObject:serverCertificateData])肯定無法驗證自己的證書,于是返回false。
相關函數追加:

static BOOL AFServerTrustIsValid(SecTrustRef serverTrust) {
    BOOL isValid = NO;
    SecTrustResultType result;
    __Require_noErr_Quiet(SecTrustEvaluate(serverTrust, &result), _out);
    
    isValid = (result == kSecTrustResultUnspecified || result == kSecTrustResultProceed);
    
_out:
    return isValid;
}

static NSArray * AFCertificateTrustChainForServerTrust(SecTrustRef serverTrust) {
    CFIndex certificateCount = SecTrustGetCertificateCount(serverTrust);
    NSMutableArray *trustChain = [NSMutableArray arrayWithCapacity:(NSUInteger)certificateCount];
    
    for (CFIndex i = 0; i < certificateCount; i++) {
        SecCertificateRef certificate = SecTrustGetCertificateAtIndex(serverTrust, i);
        [trustChain addObject:(__bridge_transfer NSData *)SecCertificateCopyData(certificate)];
    }
    
    return [NSArray arrayWithArray:trustChain];
}

最后,還需要添加一個文件頭:

#import <AssertMacros.h>

最后,在發送請求的時候,需要設置一下pinningMode。

let httpClient = AFRKHTTPClient.init(baseURL: URL(string: urlString))
let manager = RKObjectManager.init(httpClient: httpClient)
manager?.httpClient.defaultSSLPinningMode = AFRKSSLPinningModeCertificate

UIWebView

App Transport Security Settings下面除了有Allow Arbitrary Loads還有一個屬性Allow Arbitrary Loads in Web Content。只要把這個屬性設置為YES,UIWebView就可以訪問http的頁面了。不過,我不知道這個屬性設置為YES,到時候APP會不會被蘋果拒絕。
如果你仔細閱讀了上面的各種方法,那要讓UIWebView支持自簽名證書,就很簡單了。基本思路就是用URLSession來獲取頁面文本,然后調用loadHTMLString來顯示。具體代碼我就不貼了,需要的話可以到文末去下載源代碼。

代碼

源代碼在這里。如果你用的框架沒有包含在這里也不要緊,看明白上面的例子就一定能融會貫通。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,362評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,577評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,486評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,852評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,600評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,944評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,944評論 3 447
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,108評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,652評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,385評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,616評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,111評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,798評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,205評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,537評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,334評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,570評論 2 379

推薦閱讀更多精彩內容