You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

移动设备证书固定:不同平台SHA256证书指纹不一致问题问询

证书固定(Certificate Pinning)跨平台指纹差异问题解析

问题背景

在为移动应用实现证书固定时,遇到HTTP请求异常:不同移动平台返回的证书链中,CN(通用名称)一致,但SHA256编码的证书指纹却不相同,且结果与Chrome、Safari等浏览器返回的结果也存在差异。编写测试代码分析各平台证书链后,仅得到1-2个相同的指纹值。

各平台证书链指纹差异表现

Android平台

  • 2xC1bw6iB/pA4QmHVuJXllJCgjNTJxtEFISGMNlpLlg= : CN=www.google.com
  • zCTnfLwLKbS9S2sbp+uFz4KZOocFvXxkV06Ce9O5M2w= : CN=GTS CA 1C3,O=Google Trust Services LLC,C=US
  • hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc= : CN=GTS Root R1,O=Google Trust Services LLC,C=US

iOS平台

  • HX8NrTiisCa9yA43DdmJT+iuiEakuZ9VcKDGKaSCD7E= : CN=www.google.com
  • zCTnfLwLKbS9S2sbp+uFz4KZOocFvXxkV06Ce9O5M2w= : CN=GTS CA 1C3
  • EumkTYs+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 ""
    }
}

差异原因解析

  1. 哈希对象的核心差异
    Android端OkHttp的CertificatePinner默认计算的是整个证书的DER编码的SHA256哈希,而iOS代码中计算的是添加了固定RSA 2048 ASN.1头的公钥数据的SHA256哈希——两者哈希的对象完全不同,指纹自然不一致。

  2. 根证书的平台信任存储差异
    Android和iOS的系统根证书存储中,同一根CA(如GTS Root R1)可能存在多个有效版本的证书。服务器会根据客户端的根存储情况调整返回的证书链,导致根证书的指纹不同;浏览器的根证书存储与移动平台也存在差异,因此指纹结果也不一致。

  3. 请求域名的细微差异
    Android测试代码请求的是https://www.google.com,而iOS代码请求的是https://google.com。虽然会触发重定向,但服务器可能根据请求域名返回不同的终端证书(即使CN显示一致,证书本身可能不同),这直接导致终端证书的指纹差异。

  4. 证书固定实现方式混淆
    证书固定有两种常见方式:固定证书指纹(整个证书的哈希)、固定公钥指纹(公钥的哈希)。当前Android代码采用的是证书指纹方案,iOS代码采用的是公钥指纹的特殊实现,两者本就不是同一标准,结果必然存在差异。

修复建议

  • 统一哈希对象:要么都哈希整个证书的DER编码,要么都哈希公钥的标准格式
    • iOS端修改代码,改为哈希证书本身:用SecCertificateCopyData(certificate)获取证书DER数据,直接计算SHA256哈希后转Base64
    • Android端若需改为公钥哈希,需自定义逻辑提取公钥并按标准格式处理后哈希
  • 统一请求域名:确保各平台请求完全相同的域名,避免服务器返回不同证书
  • 优先固定中间证书:中间证书的更新频率比终端证书低,且比根证书更稳定,能减少因平台根存储差异导致的问题

内容的提问来源于stack exchange,提问作者Brendan C

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.31 02:39:25