iOS 中使用自签名证书实现 HTTPS 双向认证
最近项目中,需要使用自签名的 HTTPS 证书实现双向认证。网上的资料很多,但是存在各种各样的问题,与 iOS 版本、ATS 配置 等多方面因素有关。弄好之后先整一份记下来。完整的内容涉及到的内容比较多,还是要全面查阅文档,本文只记录最终的结果,和部分遇到的问题。
本文中的代码,在 iOS 8.x 和 iOS 9.x 的模拟器中测试通过,iOS 10.x 模拟器和真机中测试通过。
一、背景知识
对于 HTTPS 认证,不管是单向还是双向,在客户端连接到服务端时,会触发客户端的 Authroization Challenge(没找到太合适的翻译,暂且理解为授权质询)回调,在处理 Authroization Challenge 之后,得到两个值:(不知道怎么翻译,随便写下)
-
NSURLSessionAuthChallengeDisposition
处置方式-
NSURLSessionAuthChallengeUseCredential
使用指定的凭证,凭证可能为空 -
NSURLSessionAuthChallengePerformDefaultHandling
忽略凭证,使用默认的质询处理器 -
NSURLSessionAuthChallengeCancelAuthenticationChallenge
整个请求将被取消; 凭证参数被忽略。 -
NSURLSessionAuthChallengeRejectProtectionSpace
这个挑战被拒绝,并且应该尝试下一个 Authentication Protection Space;凭证参数被忽略。
-
-
NSURLCredential *
凭证
可以根据回调时传入的信息,自己调用相关 API 获取凭证,也可以自己伪造
将得到的两个值,作为回调函数的结果回传给系统,以完成 Authroization Challenge。
以上过程仅是对 iOS 认证过程的分析,不过个人认为,网络模型是一致的,在不同技术中即便在实现细节上有所差异,但总体思路还是大同小异的。
二、服务端认证
对于采用通过 CA 购买的正式证书,只要没有特别要求,手机端不需要对 Authroization Challenge 做任何处理,就可以直接连接。
如果是自签名证书,就需要在代码上做相关处理。iOS 8 及其之前的版本比较简单,但目前 iOS 8 在市面上的保有量已经很少了,不做细致讨论。iOS 9+ 之后,引入了 ATS,所以从代码到配置上都要做相应调整。
1、Info.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>yourdomain.com</key>
<dict>
<key>NSIncludesSubdomains</key>
<true/>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
</dict>
</plist>
这里对于域名的配置,类似是个白名单的方式,在白名单中的域名,配置为允许不安全的 HTTPS 连接。不安全的证书原因很多,常见的可能是如下原因导致的:
- 其根证书不被操作系统信任,如:自签证书
- 证书过期或被吊销
- 证书域名与实际域名不匹配
<div><div/>
配置中的 NSIncludesSubdomains
部分,建议把域名写为顶级域名,然后把 NSIncludesSubdomains
置 为 true
来包含子域名。
如果是特定的完整域名,如:www.yourdomain.com
则把 NSIncludesSubdomains
置 为 false
。后面的说法,几次验证的效果不同。
如果没有特别要求,建议使用第一种做法,写一级域名,然后包含其子域。
☆☆☆最简单粗暴的方法:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>
这种方式:
- 允许非安全的 HTTP 请求,iOS 9+ 默认是不允许 HTTP 连接的
- 对于所有域名都可以使用自签名证书,不再需要逐个指定域名
2、代码(使用 AFNetworking 3.x)
(1)简单实现
// 安全策略
// 同浏览器行为,以操作系统规则对服务器证书
AFSecurityPolicy *policy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModeNone];
// 不校验域名
policy.validatesDomainName = NO;
// 允许无效证书
policy.allowInvalidCertificates = YES;
// 为 SessionManager 配置安全策略
AFHTTPSessionManager *mgr = [AFHTTPSessionManager manager];
mgr.securityPolicy = policy;
// 发送请求
[mgr GET:...];
重要:
这种方式最简单,但是这样做只建立安全连接,但不会对服务端证书做校验,比如:不会校验证书与域名的一致性。这样做的问题是无法防御“中间人攻击”。
稍微好一些的做法:客户端内置允许的证书,不信任除此之外的证书
// 安全策略
// 改为 AFSSLPinningModeCertificate
AFSecurityPolicy *policy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModeCertificate];
// 指定验证域名。如果访问的域名与证书域名不一致,则不能通过
policy.validatesDomainName = YES;
// 对于自签证书,使用这个选项
policy.allowInvalidCertificates = YES;
// cerData1、cerData2 为 NSData,内容为 DER 格式证书
// 证书可以是 CA 证书,也可以是服务端部署的证书
policy.pinnedCertificates = [NSSet setWithObjects:cerData1, cerData2, nil];
// 为 SessionManager 配置安全策略
AFHTTPSessionManager *mgr = [AFHTTPSessionManager manager];
mgr.securityPolicy = policy;
// 发送请求
[mgr GET:...];
☆☆☆ 默认校验规则
上面说的两种方式,实际上都是使用了 AFNetworking 的默认校验规则,并且根据默认规则做了个简单实现。其规则是这样的:
AFNetworking 的 AFSecurityPolicy
类有如下方法:
- (BOOL)evaluateServerTrust:(SecTrustRef)serverTrust forDomain:(NSString *)domain
涉及到三个因素:
-
securityPolicy.allowInvalidCertificates
是否允许无效证书,个人理解这里所说的无效证书,是指类似浏览器校验行为,操作系统不认的证书 -
securityPolicy.validatesDomainName
校验域名 -
securityPolicy.SSLPinningMode
PinningMode
表面看来有如下规律:
- 如果要使用自签名证书,必须指定
allowInvalidCertificates = YES;
,否则不能通过; - 如果
allowInvalidCertificates == YES
并且SSLPinningMode == AFSSLPinningModeNone
,就是说只校验服务端证书,不管客户端内置证书,并指定为允许无效证书,则可以通过; -
SSLPinningMode == AFSSLPinningModeCertificate 或 AFSSLPinningModePublicKey
则看本地内置证书与服务端证书是否匹配 - 使用了
AFSSLPinningModeCertificate
或AFSSLPinningModePublicKey
,会导致客户端没有内置证书的网站都不能访问,如:https://www.baidu.com
默认校验规则总结:
先约定个几个名词:
正规证书 <=> 操作系统认可 and (域名一致 or 不校验域名)
有效证书 <=> 正规证书 or 允许非正规证书
无效证书 <=> 操作系统不认可 and 不允许非正规证书
- 先检查
SSLPinningMode
,如果为AFSSLPinningModeNone
,检查证书是否为有效证书即为校验结果; - 如果
SSLPinningMode
为AFSSLPinningModeCertificate
或AFSSLPinningModePublicKey
,检查证书,如果为无效证书,校验结果不通过;如果为有效证书,后续则根据本地集成证书与服务端证书一起校验结果,作为最终校验结果。 -
validatesDomainName
,只是判定因素之一,虽然影响整体校验结果,但不影响校验逻辑。如:虽然证书合法,指定做域名校验,但是证书域名与访问域名不一致,结果是不通过。
这部分的校验,可以参见官方文档:Overriding TLS Chain Validation Correctly
(2)指定身份验证质询
回调块
对于“标准场景”,达到可访问的目的,没有额外要求,上述代码已经可以了。但是对于需要额外处理的场景,如:失败的时候给出对应提示,需要使用如下方法,来指定用于处理授权质询
的回调块:
// AFURLSessionManager 类
// 指定用于处理 身份验证质询 的回调块
– setSessionDidReceiveAuthenticationChallengeBlock:
这部分的实现详情可以参见 AFNetworking 中 AFURLSessionManager.m
文件里如下方法:
// AFURLSessionManager.m
// 处理身份验证质询
- URLSession:didReceiveChallenge:completionHandler:
在这个方法中,会先查看用户是否指定了自己的回调块,如果指定了就执行用户自己的回调块,否则执行默认实现。编写自己的回调方法时,可以参考默认实现。
注意:默认实现中,只实现了服务端验证。对于客户端验证部分,只做了如下处理:
*credential = nil;
disposition = NSURLSessionAuthChallengePerformDefaultHandling;
如果要做客户端认证,重写这部分代码即可,后文中会提到。
调试注意事项:
测试时有一点需要注意,如果使用 GET
方法,应保证每次都真实发送了请求,而不是使用缓存,避免影响测试效果。坑啊!
- (推荐)客户端处理:Request 的 Header 中,指定
Cache-Control
为no-cache
- 处理 URL:为 URL 增加时间戳
- 服务端处理:Response 的 Header 中,指定
Cache-Control
为no-cache
- iOS 端设置:指定缓存策略(不推荐)
3、UIWebView
(1)使用 AFNetworking
AFNetworking 提供了 UIWebView+AFNetworking
Category,可以通过这个分类为 UIWebView 指定 sessionManager
,并调用新增加的 - loadRequest:MIMEType:textEncodingName:progress:success:failure:
方法来进行加载。但是在 Cordova 这样的组件中,还会使用 UIWebView 默认的 - loadRequest:
方法,可以配合 Method Swizzling 解决该问题。不过这样的话还是有个问题,会导致 UIWebView 的历史丢失,原因是没有使用 UIWebView 自己的方法去访问。
(2)使用 NSURLProtocol
目前对于网络认证相关的处理,效果最好、侵入性最小、对已有代码逻辑影响最小的,是 NSURLProtocol 方式。这里有个用于使 UIWebView 支持客户端认证的插件,对于服务端认证一样有效,参见 https://github.com/mwaylabs/cordova-plugin-client-certificate。
题外话,NSURLProtocol
对于很多特定场景来说更为有效。比如:曾经有项目使用了 HTTP Basic Authorization 认证。如果不使用 NSURLProtocol
的方案,可能会导致以下两种情况不能通过认证:
- 302 引发的跳转不能自动带上认证信息
- Web 页面上的
<img>
、<script>
标签、CSS、Ajax 请求不能通过认证
三、客户端认证
对于双向 HTTPS 认证来说,服务端认证是基础。客户端认证的前提,是先实现服务端认证,然后在此基础上做一下补充。
在前文中服务端授权质询处理相关的描述中,在对应位置写客户端认证的代码即可。客户端需要集成 PKCS12 格式的证书文件(由证书及其私钥文件合成),代码中内置对应密码。
详见代码示例 iOSSSLDemo。
四、相关因素及讨论
1、证书加载
如果使用 AFNetworking 的话,加载证书非常简单,只要把格式为 DER
的证书(扩展名一般为 .cer
或 .der
)集成到 Bundle,然后通过以下代码来自动加载:
policy.pinnedCertificates = [AFSecurityPolicy certificatesInBundle:[NSBundle mainBundle]];
如果需要在运行时动态加载临时获取的证书,可以通过
policy.pinnedCertificates = [NSSet setWithObjects:cerData1, cerData2, nil];
来实现。其内容为证书的 NSData
组成的 NSSet
。
2、证书的校验
@interface NSURLRequest (SSL)
@end
@implementation NSURLRequest (SSL)
+ (BOOL)allowsAnyHTTPSCertificateForHost:(NSString *)host {
return YES;
}
@end
这种使用 Category 的写法,也导致不会对证书进行校验。不过此方法有两个问题:
- 不校验证书,导致安全级别降低,容易被“中间人”方式攻击;
- 此方法为私有方法,不建议使用。
3、iOS 8
在 iOS 8 中,如果使用 AFNetworking 来实现自签名证书的认证,非常简单,只要代码部分按结论中的描述来编写即可。
有一点不太确定的,网上的资料说必须加载证书,但是实际测试,不加也可以,这个可能跟 AFSSLPinningMode
有关系,不过由于目前 iOS 8 保有量很少了,所以不再深入了。
4、iOS 9+
对于 iOS 9+ 的情况,苹果加入了 ATS,所以必须做 iOS 9 的适配 按照结论中说的,修改 ATS 部分的设置。
5、AFSSLPinningMode
AFSSLPinningMode
是安全策略的模式指定。
#import <Security/Security.h>
typedef NS_ENUM(NSUInteger, AFSSLPinningMode) {
AFSSLPinningModeNone,
//表示不做SSL pinning,只跟浏览器一样在系统的信任机构列表里验证服务端返回的证书。若证书是信任机构签发的就会通过,若是自己服务器生成的证书,这里是不会通过的。
AFSSLPinningModePublicKey,
//表示用证书绑定方式验证证书,需要客户端保存有服务端的证书拷贝,这里验证分两步,第一步验证证书的域名/有效期等信息,第二步是对比服务端返回的证书跟客户端返回的是否一致。
AFSSLPinningModeCertificate,
//这个模式同样是用证书绑定方式验证,客户端要有服务端的证书拷贝,只是验证时只验证证书里的公钥,不验证证书的有效期等信息。
};
四、代码示例
五、参考资料
(完)