认真理解iOS开发中HTTPS协议的用法

4,293 阅读9分钟

原创文章首发本人博客: blog.cocosdever.com/2019/08/01/…

文档更新说明

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

前言

  网上有很多类似文章, 但我发现其中多少有一些致命错误和误解, 本文是我经过测试,翻看权威源码之后写出的, 尽量把程序在做什么个写明白.

本文的主角就是下面这个方法, 他属于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协议的使用. 下面我就简单说一下握手的过程, 详细过程可以看到上面提到的文章.

TLS Handshake:

  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), 也就是认证服务器证书, 来帮助大家理解.

处理权威机构签发的证书

  对于权威机构签发的证书, 这类证书上面会声明自己是由哪一个CA机构(或CA的子机构)签发, 而对应的CA机构也有自己的CA证书, 在手机出厂之前就被安装进系统里了, 这样对于权威机构签发的服务器证书, 只要从系统里找一下服务器证书对应的CA证书, 拿CA证书的公钥解密一下服务器证书的签名, 解密出的Hash是不是和服务器携带的数据部分运算出的Hash一致, 即可证明服务器证书是合法的. 如果不实现didReceiveChallenge这个协议方法, 系统会自动帮忙处理好. 当然有兴趣也可以自己试一试, 下面是示例代码:

- (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即可. 其他的有兴趣的可以自行查阅代码, 源码都比较简单.