第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 天),确保新旧证书同时生效。
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 证书锁定流程图
4.6 常见错误与性能优化
| 错误类型 | 现象 | 解决方案 |
|---|---|---|
| Hash 计算错误 | 锁定后 SSLError | 检查 OpenSSL 命令是否锁定了叶证书而非 SPKI |
| 证书链不完整 | 锁定失败(chain 中缺少中间证书) | 服务端配置完整链,或客户端手动添加 TrustManager |
| 锁定过期证书 | App 在证书轮换后所有请求失败 | 使用双锁/多锁策略,提前加入新 pin |
| 通配符误用 | 子域名不匹配 | *.example.com 不匹配 example.com,需分别添加 |
性能优化: CertificatePinner 仅在首次建立连接时执行一次证书验证,后续复用连接无需重复计算,对性能影响可忽略。若使用远程配置动态更新 pin,建议缓存解析后的 pin 列表,减少每次请求时的字符串解析开销。