自签名证书适配https

2021-04-16  本文已影响0人  海耐射手

最近项目中,需要使用自签名的 HTTPS 证书实现双向认证。网上的资料很多,但是存在各种各样的问题,与 iOS 版本、ATS 配置 等多方面因素有关。弄好之后先整一份记下来。完整的内容涉及到的内容比较多,还是要全面查阅文档,本文只记录最终的结果,和部分遇到的问题。
  本文中的代码,在 iOS 8.x 和 iOS 9.x 的模拟器中测试通过,iOS 10.x 模拟器和真机中测试通过。

一、背景知识

对于 HTTPS 认证,不管是单向还是双向,在客户端连接到服务端时,会触发客户端的 Authroization Challenge(没找到太合适的翻译,暂且理解为授权质询)回调,在处理 Authroization Challenge 之后,得到两个值:(不知道怎么翻译,随便写下)

将得到的两个值,作为回调函数的结果回传给系统,以完成 Authroization Challenge。

以上过程仅是对 iOS 认证过程的分析,不过个人认为,网络模型是一致的,在不同技术中即便在实现细节上有所差异,但总体思路还是大同小异的。

二、服务端认证

对于采用通过 CA 购买的正式证书,只要没有特别要求,手机端不需要对 Authroization Challenge 做任何处理,就可以直接连接。
  如果是自签名证书,就需要做一些处理工作。iOS 8 及其之前的版本比较简单,而且目前 iOS 8 在市面上的保有量已经很少,不做细致讨论。iOS 9+ 之后引入了 ATS,带来的问题比较多所以从代码到配置上都要做相应调整。

1、白名单方式

步骤一(修改配置 Info.plist):
(1)针对域名配置

修改 Info.plist,将要访问的域名配置为 NSExceptionAllowsInsecureHTTPLoads,允许不安全的 HTTP 访问:

<?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 连接。不安全的证书原因很多,常见的可能是如下原因导致的:

配置中的 NSIncludesSubdomains 部分,建议把域名写为顶级域名,然后把 NSIncludesSubdomains 置 为 true包含子域名
  如果是特定的完整域名,如:www.yourdomain.com 则把 NSIncludesSubdomains 置 为 false。后面的说法,几次验证的效果不同。
如果没有特别要求,建议使用第一种做法,写一级域名,然后包含其子域。

(2)最简单粗暴的方法
<?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>

这种方式:

步骤二(修改代码):

修改代码:

// 安全策略
// 同浏览器行为,以操作系统规则对服务器证书
AFSecurityPolicy *policy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModeNone];
// 不校验域名,如果需要校验域名,需要采用内置证书的方式
policy.validatesDomainName = NO;
// 允许无效证书
policy.allowInvalidCertificates = YES;

// 为 SessionManager 配置安全策略
AFHTTPSessionManager *mgr = [AFHTTPSessionManager manager];
mgr.securityPolicy = policy;

// 重要!!!设置缓存策略,避免缓存
// AFNetworking 的 GET 方法缓存非常明显,一旦成功一次,后面就会直接使用缓存的结果,即便网络访问失败,也能返回成功数据,会对判断造成误导,所以一定要加上这一句!!!
[mgr.requestSerializer setValue:@"no-cache" forHTTPHeaderField:@"Cache-Control"];
// 发送请求
[mgr GET:...];

重要:
  白名单方式最简单,但是这样做只建立安全连接,但不会对服务端证书做校验,比如:不会校验证书与域名的一致性。这样做的问题是无法防御“中间人攻击”

2、内置证书方式

(1)基本实现

白名单的方案不够安全,更为安全的做法:采用内置证书的方式,将用于校验的证书内置在客户端,不信任除此之外的证书。内置的证书,可以是服务端证书,或者是用于颁发服务端证书的 CA 的证书。具体要看证书具体的签发方式。
  内置的方式,是将证书转为 DER 格式,然后以 .cer 为扩展名,作为资源放到工程中,AFNetworking 就可以自动识别了。
  同时,代码要做如下调整:

// 安全策略
// 改为 AFSSLPinningModeCertificate
AFSecurityPolicy *policy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModeCertificate];
// 指定验证域名。如果访问的域名与证书域名不一致,则不能通过
// 如果需要做域名校验,必须使用 Pinned 方式。白名单方式,不集成证书,无法校验域名
policy.validatesDomainName = YES;
// 对于自签证书,使用这个选项
policy.allowInvalidCertificates = YES;
// cerData1、cerData2 为 NSData,内容为 DER 格式证书
// 证书可以是 CA 证书,也可以是服务端部署的证书,这一步可选,AFN 可以自动识别
policy.pinnedCertificates = [NSSet setWithObjects:cerData1, cerData2, nil];

// 为 SessionManager 配置安全策略
AFHTTPSessionManager *mgr = [AFHTTPSessionManager manager];
mgr.securityPolicy = policy;

[mgr.requestSerializer setValue:@"no-cache" forHTTPHeaderField:@"Cache-Control"];
// 发送请求
[mgr GET:...];
☆☆☆ 默认校验规则

上面说的两种方式,实际上都是使用了 AFNetworking 的默认校验规则,并且根据默认规则做了个简单实现。其规则是这样的:
AFNetworking 的 AFSecurityPolicy 类有如下方法:

- (BOOL)evaluateServerTrust:(SecTrustRef)serverTrust forDomain:(NSString *)domain

涉及到三个因素:

表面看来有如下规律:

默认校验规则总结:
先约定个几个名词:

正规证书 <=> 操作系统认可 and (域名一致 or 不校验域名)
有效证书 <=> 正规证书 or 允许非正规证书
无效证书 <=> 操作系统不认可 and 不允许非正规证书

这部分的校验,可以参见官方文档:Overriding TLS Chain Validation Correctly

(2)个性化处理:指定身份验证质询回调块

对于“标准场景”,达到可访问的目的,没有额外要求,上述代码已经可以了。但是对于需要额外处理的场景,如:失败的时候给出对应提示,需要使用如下方法,来指定用于处理授权质询的回调块:

// AFURLSessionManager 类
// 指定用于处理 身份验证质询 的回调块
– setSessionDidReceiveAuthenticationChallengeBlock:

这部分的实现详情可以参见 AFNetworking 中 AFURLSessionManager.m 文件里如下方法:

// AFURLSessionManager.m
// 处理身份验证质询
- URLSession:didReceiveChallenge:completionHandler:

在这个方法中,会先查看用户是否指定了自己的回调块,如果指定了就执行用户自己的回调块,否则执行默认实现。编写自己的回调方法时,可以参考默认实现。

注意:默认实现中,只实现了服务端验证。对于客户端验证部分,只做了如下处理:

*credential = nil;
disposition = NSURLSessionAuthChallengePerformDefaultHandling;

如果要做客户端认证,重写这部分代码即可,后文中会提到。

调试注意事项:

测试时有一点需要注意,如果使用 GET 方法,应保证每次都真实发送了请求,而不是使用缓存,避免影响测试效果。坑啊!

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 的方案,可能会导致以下两种情况不能通过认证:

三、客户端认证

对于双向 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 的写法,也导致不会对证书进行校验。不过此方法有两个问题:

  1. 不校验证书,导致安全级别降低,容易被“中间人”方式攻击;
  2. 此方法为私有方法,不建议使用。

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,只跟浏览器一样在系统的信任机构列表里验证服务端返回的证书。若证书是信任机构签发的就会通过,若是自己服务器生成的证书,这里是不会通过的。

    AFSSLPinningModeCertificate,
    //表示用证书绑定方式验证证书,需要客户端保存有服务端的证书拷贝,这里验证分两步,第一步验证证书的域名/有效期等信息,第二步是对比服务端返回的证书跟客户端返回的是否一致。

    AFSSLPinningModePublicKey,
    //这个模式同样是用证书绑定方式验证,客户端要有服务端的证书拷贝,只是验证时只验证证书里的公钥,不验证证书的有效期等信息。

};
上一篇下一篇

猜你喜欢

热点阅读