第4章 证书锁定实战:CertificatePinner 配置、公钥 Hash 计算、锁定策略与更新

证书锁定(Certificate Pinning)将客户端与特定服务端证书或公钥绑定,即使 CA 被攻陷或中间人使用合规证书也无法绕过。OkHttp 的 CertificatePinner 为 Android 端提供了轻量级锁定能力,本章围绕公钥 Hash 计算、API 配置、多策略管理及生产级更新机制展开。

4.1 公钥 Hash 计算

锁定目标为服务端证书的 SubjectPublicKeyInfo 的 SHA-256 哈希(Base64 编码)。计算途径有三种:

  • OpenSSL 命令行(直接解析证书文件)
  • Python + cryptography 库(脚本化批量计算)
  • Java Keytool 配合 openssl(适用于已有 keystore)

推荐使用 OpenSSL,命令如下:

# 下载服务端证书链(示例域名 example.com:443)
openssl s_client -connect example.com:443 -showcerts < /dev/null 2>/dev/null \
  | openssl x509 -pubkey -noout \
  | openssl pkey -pubin -outform der \
  | openssl dgst -sha256 -binary \
  | openssl enc -base64

输出类似:sha256/47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=。取冒号后 Base64 部分作为 pin 值。

提示:生产环境中通常会锁定中间证书的公钥,而非叶证书,因为叶证书更新频繁,中间证书相对稳定。

4.2 CertificatePinner 配置

OkHttp 的 CertificatePinner 通过 Builder 注册域名与 pin 组合,支持通配符域名。配置后每次握手自动验证服务端证书链中至少一个公钥匹配 pin 值。

// 创建一个 CertificatePinner,锁定 example.com 的中间证书公钥
CertificatePinner certificatePinner = new CertificatePinner.Builder()
    .add("example.com", "sha256/47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=")
    .add("*.example.com", "sha256/47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=")
    .build();

OkHttpClient client = new OkHttpClient.Builder()
    .certificatePinner(certificatePinner)
    .build();

注意:pin 值必须以 sha256/ 为前缀,后跟 Base64 编码的 SHA-256 哈希。

4.3 锁定策略与更新机制

工程中常见三种锁定策略:

策略描述适用场景
固定单锁仅锁定一个公钥 Hash次要/测试环境,风险高
双锁(主备)同时锁定当前公钥和备用公钥正式环境,允许证书轮换
多锁 + 热更新通过远程配置动态更新 pin 列表大型 App,支持灰度发布

4.3.1 双锁示例

CertificatePinner pinner = new CertificatePinner.Builder()
    .add("api.example.com", "sha256/AAAA...")   // 当前证书
    .add("api.example.com", "sha256/BBBB...")   // 备用证书(未来轮换)
    .add("api.example.com", "sha256/CCCC...")   // 灾难恢复证书
    .build();

OkHttp 验证时会检查所有 pin,只要有一个匹配就视为通过。当需要轮换证书时,提前将新公钥 Hash 加入列表,待旧证书过期后再移除。

4.3.2 动态更新策略

结合 Firebase Remote Config 或自研配置中心,App 启动时拉取最新的 pin 列表,再动态构建 CertificatePinner。需注意:

  • 默认 pin 硬编码在代码中,作为 fallback 兜底。
  • 远程配置需签名验证,防止篡改。
  • 更新时保留旧 pin 一段重叠期(通常 30~90 天),确保新旧证书同时生效。
警告:不要在远程配置尚未生效时移除所有旧 pin,否则会导致所有请求因锁定失败而中断!

4.4 调试与验证

启用 OkHttp 的详细日志可查看证书锁定过程:

HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
logging.setLevel(HttpLoggingInterceptor.Level.HEADERS);
OkHttpClient client = new OkHttpClient.Builder()
    .addInterceptor(logging)
    .certificatePinner(pinner)
    .build();

锁定时常见的异常信息:

javax.net.ssl.SSLPeerUnverifiedException: 
Certificate pinning failure!
  Peer certificate chain:
    sha256/yyyy...: CN=example.com
  Pinned certificates for example.com:
    sha256/zzzz...
  Check pinning: certificate_pinner

此时需核对 pin 是否与计算所得一致,或检查是否锁定了错误的证书(例如锁了叶证书但中间证书已更新)。

4.5 证书锁定流程图

证书锁定实战流程 1. 计算公钥 Hash 2. 配置 Pinner 3. 选择锁定策略 4. 构建 Client 5. 调试 / 验证 6. 远程配置 & 更新 关键提示 • 锁定中间证书公钥(更稳定) • 双锁策略避免单点故障 • 远程更新必须签名 & 保留旧 pin 过渡期

4.6 常见错误与性能优化

错误类型现象解决方案
Hash 计算错误锁定后 SSLError检查 OpenSSL 命令是否锁定了叶证书而非 SPKI
证书链不完整锁定失败(chain 中缺少中间证书)服务端配置完整链,或客户端手动添加 TrustManager
锁定过期证书App 在证书轮换后所有请求失败使用双锁/多锁策略,提前加入新 pin
通配符误用子域名不匹配*.example.com 不匹配 example.com,需分别添加

性能优化: CertificatePinner 仅在首次建立连接时执行一次证书验证,后续复用连接无需重复计算,对性能影响可忽略。若使用远程配置动态更新 pin,建议缓存解析后的 pin 列表,减少每次请求时的字符串解析开销。

实战要点总结:公钥 Hash 计算是前提,双锁策略是生产标配,动态更新保障长期可用。始终保留至少一个备用 pin,并监控锁定失败日志,及时响应证书轮换。