4、iOS证书绑定实现:AFNetworking与Alamofire中的证书绑定、NSURLSession的挑战处理、使用SecTrustStore
iOS端的证书绑定,说实话,坑比Android多。为什么呢?因为Apple对网络层的抽象更彻底,你没法像在OkHttp里那样直接塞一个CertificatePinner完事。你得跟NSURLSession的挑战回调打交道,还得理解SecTrustStore这套底层的Security框架。
我最早做这个的时候,以为AFNetworking里配个证书就万事大吉了。结果上线第一天,用户反馈连不上——因为证书过期了,而我写死了公钥的哈希。嗯,从那以后,我再也不敢只绑证书文件了。
4.1 理解NSURLSession的挑战处理机制
所有iOS网络库,底层都跑在NSURLSession上。AFNetworking和Alamofire只是帮你封装了回调。所以,我们先搞懂最核心的东西——URLSession:didReceiveChallenge:completionHandler:。
这个回调会在两种情况下触发:
- 服务器要求客户端证书(双向认证)
- 服务器证书需要验证(单向认证 + 绑定)
我们要处理的,主要是第二种。回调里会给你一个URLAuthenticationChallenge,里面包含protectionSpace,再往下拿serverTrust。这个SecTrustRef对象,就是证书绑定的战场。
核心逻辑:不要直接调用completionHandler(.performDefaultHandling, ...)。那样等于把验证全权交给系统,你的绑定策略就失效了。一定要走.useCredential或.cancelAuthenticationChallenge。
4.2 AFNetworking中的证书绑定实现
AFNetworking 3.x之后,提供了AFSecurityPolicy。很多人以为设成AFSSLPinningModeCertificate就完事了。我见过一个项目,直接把p12文件塞进bundle,然后设了这个模式——结果证书一换,全崩。
正确的做法是:
- 把服务端证书(.cer)导入项目
- 在
AFSecurityPolicy里设置pinnedCertificates - 选择
AFSSLPinningModePublicKey,而不是AFSSLPinningModeCertificate
为什么我推荐公钥绑定?因为证书会过期,但公钥可以不变。你想想看,换证书时只要保证密钥对不变,客户端就不用发版。
// AFNetworking 公钥绑定示例
NSString *cerPath = [[NSBundle mainBundle] pathForResource:@"server" ofType:@"cer"];
NSData *cerData = [NSData dataWithContentsOfFile:cerPath];
NSSet *certSet = [NSSet setWithObject:cerData];
AFSecurityPolicy *policy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModePublicKey
withPinnedCertificates:certSet];
policy.validatesDomainName = YES; // 一定要开,防止中间人用合法证书但域名不对
AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];
manager.securityPolicy = policy;
个人习惯:我会在AFSecurityPolicy里同时保留2-3个备用公钥。一个是当前证书的,一个是备份密钥对的,还有一个是紧急回退的。这样就算密钥轮换,也不会断服。
4.3 Alamofire中的证书绑定实现
Alamofire 5.x之后,推荐用ServerTrustManager + PinnedCertificatesTrustEvaluator。这个组合比AFNetworking更灵活,但也更容易配错。
我记得有一次帮一个团队排查问题,他们用了DefaultTrustEvaluator,以为系统会自动做绑定——结果系统只验证了证书链有效性,根本没做绑定。攻击者只要有个合法CA签发的证书就能绕过。
// Alamofire 公钥绑定示例
import Alamofire
let evaluators: [String: ServerTrustEvaluating] = [
"api.example.com": PinnedCertificatesTrustEvaluator(
certificates: [Certificates.server],
performDefaultValidation: false, // 关闭默认验证,完全由我们控制
validateHost: true // 必须验证主机名
)
]
let manager = ServerTrustManager(evaluators: evaluators)
let session = Session(serverTrustManager: manager)
这里有个关键参数:performDefaultValidation。设成false后,系统不会做CA链验证,完全依赖你提供的证书列表。说白了,你绑了什么,它就信什么。这样更安全,但也要更小心——一旦你提供的证书列表出错,整个连接就断了。
我曾经踩过的坑:在Alamofire里同时绑了根证书和中间证书,结果中间证书轮换时忘了更新。客户端拿着旧的中间证书去匹配,永远匹配不上。后来我只绑根证书的公钥,中间证书随便换,客户端不受影响。
4.4 使用SecTrustStore进行底层绑定
如果你不想用AFNetworking或Alamofire,或者你需要更精细的控制,可以直接操作SecTrustStore。这个API属于Security框架,是iOS证书验证的底层基石。
流程是这样的:
- 从
URLAuthenticationChallenge里取出serverTrust - 用
SecTrustSetAnchorCertificates设置你信任的锚点证书 - 调用
SecTrustEvaluate进行验证 - 检查验证结果,并比对公钥
// 底层SecTrustStore绑定示例
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// 加载本地存储的锚点证书
guard let anchorCertPath = Bundle.main.path(forResource: "myCA", ofType: "cer"),
let anchorCertData = NSData(contentsOfFile: anchorCertPath),
let anchorCert = SecCertificateCreateWithData(nil, anchorCertData) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// 设置锚点证书
SecTrustSetAnchorCertificates(serverTrust, [anchorCert] as CFArray)
SecTrustSetAnchorCertificatesOnly(serverTrust, true) // 只信任我们指定的锚点
// 评估信任
var trustResult: SecTrustResultType = .invalid
SecTrustEvaluate(serverTrust, &trustResult)
// 检查结果
if trustResult == .unspecified || trustResult == .proceed {
// 额外比对公钥(双重保险)
if let publicKey = SecTrustCopyPublicKey(serverTrust) {
// 比对publicKey与本地存储的公钥哈希
let localKeyHash = "your-public-key-hash-base64"
let serverKeyHash = hashPublicKey(publicKey)
if serverKeyHash == localKeyHash {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
return
}
}
}
completionHandler(.cancelAuthenticationChallenge, nil)
}
关键点:SecTrustSetAnchorCertificatesOnly(true)这个调用很重要。设成true后,系统只会用你指定的证书做锚点,不会再去系统根证书库查找。这样即使攻击者拿到了CA签发的证书,只要不在你的锚点列表里,照样拒绝。
4.5 三种实现方式的对比
| 方案 | 封装层级 | 灵活性 | 维护成本 | 推荐场景 |
|---|---|---|---|---|
| AFNetworking AFSecurityPolicy | 高 | 中 | 低 | OC项目、快速集成 |
| Alamofire ServerTrustManager | 高 | 高 | 中 | Swift项目、需要精细控制 |
| SecTrustStore原生API | 低 | 最高 | 高 | 自定义网络层、特殊安全需求 |
我个人建议:能用Alamofire就用Alamofire。它的ServerTrustManager设计得比较合理,支持多域名、多证书、动态评估器。AFNetworking虽然也能用,但它的AFSSLPinningModeCertificate模式有个问题——它比对的是整个证书数据,一旦证书序列号变了(比如重新签发),即使公钥没变也会失败。
4.6 证书绑定策略的SVG流程图
下面这张图,展示了iOS证书绑定的完整决策流程。从NSURLSession发起请求,到挑战回调,再到三种绑定方式的选择,最后到连接建立或拒绝。
4.7 避坑指南与最佳实践
做了这么多年iOS安全,我总结了几条铁律:
- 不要只绑一个证书。至少准备2-3个备用公钥,包括一个紧急回退密钥。我曾经见过一个金融App,只绑了一个证书,结果证书私钥泄露,整个团队加班三天换证书、发版、推更新。
- 公钥绑定优于证书绑定。证书会过期,公钥不会。只要密钥对不变,换证书不影响绑定逻辑。
- 一定要验证域名。不管是AFNetworking的
validatesDomainName,还是Alamofire的validateHost,都要打开。否则攻击者可以用一个合法证书但指向不同域名来绕过。 - 测试环境要区分。开发、测试、生产环境用不同的证书和公钥。我见过有人把测试环境的证书带到生产环境,结果上线后所有请求都走测试链路,数据全乱了。
- 做好降级预案。如果证书绑定失败,不要直接闪退或死循环。可以设计一个降级策略——比如提示用户更新App,或者走一个备用的安全通道。
我曾经犯过的错:有一次在Alamofire里同时绑了根证书和叶子证书,结果叶子证书轮换时忘了更新bundle。用户端拿着旧的叶子证书去匹配,永远匹配不上。后来我改成只绑根证书的公钥,中间证书和叶子证书随便换,客户端完全不受影响。
嗯,iOS证书绑定这块,说白了就是三个选择:用AFNetworking省心,用Alamofire灵活,用SecTrustStore掌控一切。选哪个取决于你的团队能力和项目需求。但不管选哪个,公钥绑定 + 多备用密钥 + 域名验证,这三板斧一定要上。