SSL Pinning 的进阶策略:动态 Pinning、备份 Pinning、多域名 Pinning
静态 Pinning 虽然简单,但有个致命问题——证书换不了。我早年在一个金融项目里就吃过这个亏,证书过期那天,线上用户集体断连,紧急发版都来不及。从那以后,我对动态 Pinning 和备份 Pinning 就格外上心。
说白了,静态 Pinning 是把证书钉死在代码里。动态 Pinning 则是让钉子的位置可以远程调整。备份 Pinning 是给钉子加个保险。多域名 Pinning 嘛,就是同时钉好几个目标。
咱们一个一个聊。
动态 Pinning:让证书策略活起来
动态 Pinning 的核心思路很简单:证书指纹不写死在代码里,而是从远程服务器获取。这样证书更新时,你只需要改服务端的配置,客户端不用发版。
我个人习惯的做法是这样的:
- App 启动时,先请求一个配置接口,拿到当前有效的证书指纹列表
- 把这个指纹列表缓存到本地(SharedPreferences 或本地文件)
- 后续的 HTTPS 请求,用这个列表做 Pinning 校验
- 如果配置接口请求失败,就用上一次缓存的指纹
来看一段核心代码:
// 动态 Pinning 的核心逻辑
public class DynamicPinningInterceptor implements Interceptor {
private static final String CONFIG_URL = "https://config.yourdomain.com/pinning.json";
private Set<String> pinnedHashes = new HashSet<>();
@Override
public Response intercept(Chain chain) throws IOException {
// 1. 先尝试更新 Pinning 配置
refreshPinningConfig();
// 2. 获取目标证书的 SHA256 指纹
Request request = chain.request();
Response response = chain.proceed(request);
String certHash = extractCertHash(response);
// 3. 校验指纹是否在白名单中
if (!pinnedHashes.contains(certHash)) {
throw new SSLPeerUnverifiedException("证书指纹不匹配!");
}
return response;
}
private void refreshPinningConfig() {
// 从远程获取最新的证书指纹列表
// 失败时使用本地缓存
}
}
关键点:动态 Pinning 的配置接口本身也要走 HTTPS,而且最好也做 Pinning。不然就陷入「先有鸡还是先有蛋」的死循环了。
我的经验:配置接口的域名和业务接口的域名分开。这样即使业务域名证书出问题,配置接口还能正常工作,App 就能通过动态更新来「自救」。
备份 Pinning:给证书策略上双保险
备份 Pinning 是什么意思?就是除了主证书,你还得准备一个或多个备用证书的指纹。一旦主证书失效,备用证书能顶上。
我曾经在一个 IoT 项目里遇到过这种情况:设备端的根证书因为 CA 机构被吊销,所有设备一夜之间全部离线。幸好我们做了备份 Pinning,紧急切换到备用 CA 的证书,才没造成更大损失。
备份 Pinning 的典型做法:
- 主证书:当前正在使用的服务器证书
- 备用证书 1:同一 CA 签发的备用证书(提前申请好)
- 备用证书 2:不同 CA 签发的证书(防止 CA 本身出问题)
- 备用证书 3:自签名证书(最后的保底方案)
代码实现上,其实就是把多个指纹放到一个集合里:
// 备份 Pinning 的指纹集合
Set<String> backupPins = new HashSet<>() {{
// 主证书指纹
add("sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
// 备用证书 1(同一 CA)
add("sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=");
// 备用证书 2(不同 CA)
add("sha256/CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=");
// 备用证书 3(自签名)
add("sha256/DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=");
}};
注意:备用证书的私钥一定要妥善保管。我见过有人把备用证书的私钥直接放在 Git 仓库里,那跟没做 Pinning 有什么区别?
多域名 Pinning:一个 App 对接多个服务
现在的 App 很少只连一个后端。API 服务器、图片 CDN、日志上报、推送服务……每个域名都可能需要独立的 Pinning 策略。
多域名 Pinning 的核心是:为每个域名维护独立的证书指纹列表。你不能用一个指纹去校验所有域名,那会出大问题。
我建议的做法是维护一个域名到指纹列表的映射表:
// 多域名 Pinning 配置
Map<String, Set<String>> domainPins = new HashMap<>() {{
// API 服务
put("api.yourdomain.com", new HashSet<>() {{
add("sha256/AAAA...");
add("sha256/BBBB...");
}});
// 图片 CDN
put("cdn.yourdomain.com", new HashSet<>() {{
add("sha256/CCCC...");
add("sha256/DDDD...");
}});
// 日志上报
put("log.yourdomain.com", new HashSet<>() {{
add("sha256/EEEE...");
add("sha256/FFFF...");
}});
}};
然后在拦截器里根据请求的 host 来选择对应的指纹列表:
@Override
public Response intercept(Chain chain) throws IOException {
Request request = chain.request();
String host = request.url().host();
// 获取该域名对应的 Pinning 列表
Set<String> pins = domainPins.get(host);
if (pins == null) {
// 未配置的域名,走默认策略
return chain.proceed(request);
}
// 校验证书指纹
String certHash = extractCertHash(chain.proceed(request));
if (!pins.contains(certHash)) {
throw new SSLPeerUnverifiedException(
"域名 " + host + " 证书指纹不匹配"
);
}
return chain.proceed(request);
}
核心原则:每个域名独立管理,互不影响。一个域名的证书更新不会影响其他域名的正常通信。
三种策略的对比与选择
| 策略 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 静态 Pinning | 证书极少变更、可控制发版节奏 | 实现简单,无额外依赖 | 证书更新必须发版 |
| 动态 Pinning | 证书频繁变更、需要远程控制 | 无需发版即可更新 | 依赖配置接口可用性 |
| 备份 Pinning | 高可用要求、容灾场景 | 证书失效时有备用方案 | 管理多个证书较复杂 |
| 多域名 Pinning | 多后端服务、微服务架构 | 域名间隔离,互不影响 | 配置量大,维护成本高 |
实际项目中,这三种策略往往是组合使用的。比如:动态 Pinning + 备份 Pinning,或者多域名 Pinning + 静态 Pinning。没有银弹,只有最适合你业务场景的方案。
知识体系总览
下面这张图把三种进阶策略的关系梳理清楚了:
避坑指南:我曾经在一个项目里同时用了动态 Pinning 和备份 Pinning,但忘了给配置接口本身做 Pinning。结果配置接口被中间人攻击,返回了错误的指纹列表,所有业务请求全部被拦截。嗯,这个坑我替你们踩过了。
最后说一句:Pinning 策略没有绝对的对错,只有合不合适。你的 App 证书一年换一次?静态 Pinning 就够了。你的 App 对接了十几个微服务?多域名 Pinning 是标配。你的 App 对可用性要求极高?动态 + 备份 Pinning 必须上。
想清楚你的业务场景,再选策略。别为了炫技而过度设计。
公众号:蓝海资料掘金营,微信deep3321