美文网首页
用自签名证书(self-signed certificate)终

用自签名证书(self-signed certificate)终

作者: 阿影 | 来源:发表于2017-03-03 18:37 被阅读5428次

    本文用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来显示。具体代码我就不贴了,需要的话可以到文末去下载源代码。

    代码

    源代码在这里。如果你用的框架没有包含在这里也不要紧,看明白上面的例子就一定能融会贯通。

    相关文章

      网友评论

          本文标题:用自签名证书(self-signed certificate)终

          本文链接:https://www.haomeiwen.com/subject/qyhnvttx.html