认真理解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便可. 其余的有兴趣的能够自行查阅代码, 源码都比较简单.

相关文章
相关标签/搜索