移动设备证书固定:不同平台SHA256证书指纹不一致问题问询
问题背景
在为移动应用实现证书固定时,遇到HTTP请求异常:不同移动平台返回的证书链中,CN(通用名称)一致,但SHA256编码的证书指纹却不相同,且结果与Chrome、Safari等浏览器返回的结果也存在差异。编写测试代码分析各平台证书链后,仅得到1-2个相同的指纹值。
各平台证书链指纹差异表现
Android平台
2xC1bw6iB/pA4QmHVuJXllJCgjNTJxtEFISGMNlpLlg=: CN=www.google.comzCTnfLwLKbS9S2sbp+uFz4KZOocFvXxkV06Ce9O5M2w=: CN=GTS CA 1C3,O=Google Trust Services LLC,C=UShxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=: CN=GTS Root R1,O=Google Trust Services LLC,C=US
iOS平台
HX8NrTiisCa9yA43DdmJT+iuiEakuZ9VcKDGKaSCD7E=: CN=www.google.comzCTnfLwLKbS9S2sbp+uFz4KZOocFvXxkV06Ce9O5M2w=: CN=GTS CA 1C3EumkTYs+nSg5q/mGi38Fjyg/I7lBU59PhayJy7/fx5k=: CN=GTS Root R1
测试代码分析
Android测试代码(Java)
void analyzeCerts() { Gson gson = new GsonBuilder() .setPrettyPrinting() .create(); HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); logging.setLevel(HttpLoggingInterceptor.Level.BASIC); // Change this to enable logging. OkHttpClient.Builder httpClient = new OkHttpClient.Builder() .connectTimeout(DateTimeConstants.SECONDS_PER_MINUTE, TimeUnit.SECONDS) .readTimeout(DateTimeConstants.SECONDS_PER_MINUTE, TimeUnit.SECONDS); httpClient.addInterceptor(logging); String url = "https://www.google.com"; String pinUrl = "www.google.com"; CertificatePinner certificatePinner = new CertificatePinner.Builder() .add(pinUrl, "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") .build(); OkHttpClient.Builder clientWithPinner = httpClient.certificatePinner(certificatePinner); Retrofit retrofit = new Retrofit.Builder() .baseUrl(url) .addConverterFactory(GsonConverterFactory.create(gson)) .client(clientWithPinner.build()) .build(); SimpleInterface si = retrofit.create(SimpleInterface.class); Call<ResponseBody> call = si.getPublicConsentsByFlow("abcd", "efg"); call.enqueue(new Callback<ResponseBody>() { @Override public void onResponse(Call<ResponseBody> call, Response<ResponseBody> response) { Log.i(CERT_APP, "In onResponse" + response.toString()); } @Override public void onFailure(Call<ResponseBody> call, Throwable t) { Log.i(CERT_APP, "In onFailure " + t); } }); }
iOS测试代码(Swift)
import Foundation import CryptoKit class ServiceRequest: NSObject, URLSessionDelegate { private let rsa2048Asn1Header: [UInt8] = [ 0x30, 0x82, 0x01, 0x22, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, 0x82, 0x01, 0x0f, 0x00 ] enum ServiceRequestMethod: String { case get = "GET" case post = "POST" case put = "PUT" case delete = "DELETE" } var session: URLSession! override init() { super.init() session = URLSession(configuration: .default, delegate: self, delegateQueue: nil) self.certPinTestRequest() } func certPinTestRequest(){ let request = NSMutableURLRequest(url: URL(string: "https://google.com")!) request.httpMethod = ServiceRequestMethod.get.rawValue request.timeoutInterval = 20.0 session.dataTask(with: request as URLRequest) { (data, response, error) -> Void in }.resume() } func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { if let trust = challenge.protectionSpace.serverTrust, SecTrustGetCertificateCount(trust) > 0 { for index in 0..<SecTrustGetCertificateCount(trust) { // Get the public key data for the certificate at the current index of the loop. if let certificate = SecTrustGetCertificateAtIndex(trust, index), let publicKey = SecCertificateCopyPublicKey(certificate), let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, nil){ let keyHash = hash(data: (publicKeyData as NSData) as Data) let certSummary = SecCertificateCopySubjectSummary(certificate) ?? NSString(string: "nil") print("sha256/\(keyHash) CN=\(certSummary)") } } completionHandler(.useCredential, URLCredential(trust: trust)) return } completionHandler(.cancelAuthenticationChallenge, nil) } private func hash(data: Data) -> String { var keyWithHeader = Data(rsa2048Asn1Header) keyWithHeader.append(data) if #available(iOS 13.0, *) { return Data(SHA256.hash(data: keyWithHeader)).base64EncodedString() } else { // Fallback on earlier versions } return "" } }
差异原因解析
哈希对象的核心差异
Android端OkHttp的CertificatePinner默认计算的是整个证书的DER编码的SHA256哈希,而iOS代码中计算的是添加了固定RSA 2048 ASN.1头的公钥数据的SHA256哈希——两者哈希的对象完全不同,指纹自然不一致。根证书的平台信任存储差异
Android和iOS的系统根证书存储中,同一根CA(如GTS Root R1)可能存在多个有效版本的证书。服务器会根据客户端的根存储情况调整返回的证书链,导致根证书的指纹不同;浏览器的根证书存储与移动平台也存在差异,因此指纹结果也不一致。请求域名的细微差异
Android测试代码请求的是https://www.google.com,而iOS代码请求的是https://google.com。虽然会触发重定向,但服务器可能根据请求域名返回不同的终端证书(即使CN显示一致,证书本身可能不同),这直接导致终端证书的指纹差异。证书固定实现方式混淆
证书固定有两种常见方式:固定证书指纹(整个证书的哈希)、固定公钥指纹(公钥的哈希)。当前Android代码采用的是证书指纹方案,iOS代码采用的是公钥指纹的特殊实现,两者本就不是同一标准,结果必然存在差异。
修复建议
- 统一哈希对象:要么都哈希整个证书的DER编码,要么都哈希公钥的标准格式
- iOS端修改代码,改为哈希证书本身:用
SecCertificateCopyData(certificate)获取证书DER数据,直接计算SHA256哈希后转Base64 - Android端若需改为公钥哈希,需自定义逻辑提取公钥并按标准格式处理后哈希
- iOS端修改代码,改为哈希证书本身:用
- 统一请求域名:确保各平台请求完全相同的域名,避免服务器返回不同证书
- 优先固定中间证书:中间证书的更新频率比终端证书低,且比根证书更稳定,能减少因平台根存储差异导致的问题
内容的提问来源于stack exchange,提问作者Brendan C

