简短回答:浏览器已经放弃了基于 HTTP 头部的证书固定(HPKP):Chrome 在 72 版(2019年1月)中移除了它,Firefox 在 72 版(2020年1月)中移除,而它原本要发现的问题,如今大部分已由证书透明度(CT)覆盖。证书固定依然存在于那些连接你自己控制的服务器的移动应用和 API 客户端中。如果要固定,应固定公钥(SPKI)的 SHA-256 哈希,而不是证书;固定你会继续使用的 CA 的密钥(中间证书或根证书),而不是叶子证书的密钥,因为大多数 ACME 客户端每次续期都会更换叶子密钥;始终附带至少一个备用固定值,它应来自另一家 CA 或一个离线密钥;并使用平台机制:Android 上用网络安全配置(Network Security Configuration)并设置到期日期,iOS 上用 NSPinnedDomains。Google 和 Apple 都建议:除非有具体的理由,否则不要使用证书固定。
什么是固定值
普通的证书验证会接受任何一条通向信任库中任一根证书的链。固定值增加了一个条件:经过验证的链中至少要有一个密钥在你的列表上。列表中存放的是 SubjectPublicKeyInfo(SPKI,即 DER 编码的算法标识符和公钥)的哈希。对密钥而不是对证书做哈希,意味着用同一密钥续期的证书仍然匹配,而针对 CA 密钥的固定值能匹配该 CA 签发的每一张证书。
固定值用 OpenSSL 从证书计算得出。下面是 letsencrypt.org 在2026年10月20日返回的证书链(eclipse-temurin:25.0.4.1_1-jdk-noble,OpenSSL 3.0.13):
openssl s_client -connect letsencrypt.org:443 -servername letsencrypt.org -showcerts </dev/null 2>/dev/null \
| awk '/-----BEGIN CERTIFICATE-----/{n++; f=sprintf("c%d.pem", n); p=1} p{print >> f} /-----END CERTIFICATE-----/{p=0; close(f)}'
pin() { openssl x509 -in "$1" -noout -pubkey | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64; }
for f in c*.pem; do printf '%s %s\n' "$(pin "$f")" "$(openssl x509 -in "$f" -noout -subject | sed 's/^subject=//')"; done
x8ltqh+zNPYG7RZS+IjFymndoy6YAWLPaSkptqebzfc= CN = letsencrypt.org
s/tdAOmUzd8syaTuqfgGvFcn6DzA5Cmb+Vby1ST+U3Y= C = US, O = Let's Encrypt, CN = YE2
sCkq5UWXjg+7mKu9lMhhYF5bGLsy7VI/UNW3tccdR7w= C = US, O = ISRG, CN = Root YE
diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI= C = US, O = Internet Security Research Group, CN = ISRG Root X2
这四个候选值随时间推移的表现截然不同,大部分工作就在于从中做出选择。SPKI 固定值生成器在浏览器中计算出同样的值,并输出配置代码片段。
浏览器为何放弃了 HPKP
HTTP 公钥固定(RFC 7469,2015年)允许网站发送 Public-Key-Pins 响应头,其中包含密钥哈希和 max-age;在这段时间内,浏览器会拒绝任何不含其中某个密钥的证书链。导致它被淘汰的是两种故障模式:
- 自己造成的服务中断:密钥丢失、更换 CA 或一处笔误,都会让每一位再次访问的用户无法访问网站,直到
max-age到期,而服务器端没有任何办法撤销。 - 恶意固定:短暂控制了网站(或其证书签发)的攻击者,可以发送只对应攻击者自己所持密钥的固定值,把一次短暂的入侵变成长时间的拒绝服务。
Chrome 的移除条目给出的理由是“采用率极低”以及“拒绝服务和恶意固定的风险”(chromestatus);桌面版和 Android 版 Chrome 72 移除了对它的支持,Firefox 随后也在 72 版中移除。浏览器至今仍内置一份很小的固定值列表,用于浏览器厂商自己的域名,但任何网站都无法把自己加进去。
取而代之的是检测,而不是预防。自2018年起,Chrome 和 Apple 平台只接受带有来自证书透明度日志的签名证书时间戳(SCT)的公开受信任 TLS 证书,因此为你的域名签发的每一张证书都是公开的,可以监控;CAA 记录则告诉各家 CA,其中哪些可以为你的域名签发证书。两者结合,HPKP 所针对的错误签发能在几小时内被发现,而且没有把用户挡在门外的风险。
固定什么
| 固定对象 | 不受影响的变化 | 失效的情况 |
|---|---|---|
| 叶子证书密钥 | 保留原密钥的续期 | 每次换用新密钥的续期,而这正是 certbot 的默认行为 |
| 签发用的中间证书(YE2) | 叶子证书续期和密钥更换 | CA 轮换中间证书;Let's Encrypt 有多个中间证书在轮换使用,还有若干备用中间证书,它“可能随时开始用……签发证书,不会提前警告” |
| 根证书(ISRG Root X2) | 中间证书轮换 | 你更换 CA,或该 CA 不再受信任 |
| 你离线保存的密钥 | 任何变化,只要它一直未被使用 | 只有在你把它弄丢时 |
叶子证书这一行值得特别强调,因为它是默认就会掉进去的陷阱。certbot --help all(v5.8.0)说明 --reuse-key 默认关闭,并写道:“不复用私钥是 Certbot 的默认行为”。因此,叶子证书的固定值会在下一次续期时失效,而续期的间隔还在不断缩短:CA/Browser Forum 自2026年3月起把 TLS 证书的有效期上限定为 200 天,2027年3月起为 100 天,2029年3月起为 47 天;Let's Encrypt 的默认有效期将于2027年2月改为 64 天。
中间证书这一行则比较隐蔽。letsencrypt.org 的证书由 YE2 签发,但 Let's Encrypt 列出的正在轮换使用的中间证书有 YE1 和 YE2(ECDSA)以及 YR1 和 YR2(RSA),另有 YE3 和 YR3 作为备用(信任链)。只包含 YE2 的固定值集合,在下一张证书改由 YE1 签发时就会失效。
根证书的固定值能经受住上述所有变化,只有在你更换 CA 时才会失效。而这种情况比想象中更常见:Chrome 停止信任最早 SCT 晚于2024年11月11日的 Entrust 签发证书时(Google 安全博客),每一家 Entrust 客户都必须在几个月内迁移到另一家 CA。如果应用固定了 Entrust 的根证书而没有备用固定值,就必须先发布更新。
常见的折中方案是:固定当前 CA 的根证书(或它的两个根证书),加上一家你可以切换过去的备选 CA 的根证书,还可以再加上一个你自己生成并离线保存、留作应急证书之用的密钥。以 Let's Encrypt 为当前 CA、Google Trust Services 为备选 CA,从 Debian 的 ca-certificates 20260816 中得到的固定值如下:
C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M= subject=C=US, O=Internet Security Research Group, CN=ISRG Root X1 notAfter=Jun 4 11:04:38 2035 GMT
diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI= subject=C=US, O=Internet Security Research Group, CN=ISRG Root X2 notAfter=Sep 17 16:00:00 2040 GMT
mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c= subject=C=US, O=Google Trust Services LLC, CN=GTS Root R4 notAfter=Jun 22 00:00:00 2036 GMT
hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc= subject=C=US, O=Google Trust Services LLC, CN=GTS Root R1 notAfter=Jun 22 00:00:00 2036 GMT
ISRG Root X2 的固定值能匹配 letsencrypt.org 发送的最后一张证书(由 X1 交叉签名的 X2),因为这张证书与信任库中自签名的 X2 使用同一个密钥。固定一家你尚未使用的 CA 的根证书不需要任何代价,却给你留了一条退路。
Android:网络安全配置
Android 7.0 及更高版本从 res/xml/network_security_config.xml 读取固定值,该文件由清单文件中的 android:networkSecurityConfig 引用(文档):
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2027-10-01">
<!-- ISRG Root X2 and X1: the current CA, ECDSA and RSA chains -->
<pin digest="SHA-256">diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=</pin>
<pin digest="SHA-256">C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=</pin>
<!-- GTS Root R4 and R1: the CA we would switch to -->
<pin digest="SHA-256">mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=</pin>
<pin digest="SHA-256">hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=</pin>
</pin-set>
</domain-config>
</network-security-config>
文档中的关键规则:
- 证书链只要“包含至少一个已固定的公钥”就会被接受;摘要只能是
SHA-256。 - 过了
expiration(yyyy-MM-dd)之后,就不再执行证书固定。这样,即使你早已忘了那些旧安装,它们也能继续工作;文档也警告,在装有过时应用的设备上,这“可能让攻击者绕过你固定的证书”。到期日期应选在你下次更新固定值的日期之后大约一个发布周期。 - 当应用处于可调试状态时,
<debug-overrides>中的信任锚会绕过固定值,开发时正是靠这一点查看流量;在<certificates>元素上设置overridePins="true"会在发布版本中起同样的作用,不应出现在交付的应用中。 - Android 16 在同一个文件中加入了证书透明度检查(
<certificateTransparency enabled="true"/>),而且对于以 Android 17 为目标版本的应用,从 Android 17 起这项检查默认开启,使用自定义信任锚的连接除外。对很多应用来说,这正是它们原本想通过证书固定获得的保护。
Google 自己的建议毫不含糊:证书固定“不建议用于 Android 应用”;如果一定要用,应包含“多个备用固定值,其中至少有一个完全由你掌控的密钥,并设置足够短的到期期限”(网络协议安全)。
OkHttp
OkHttp 的 CertificatePinner 可以在 Android 和 JVM 上使用,而 JVM 上没有网络安全配置。下面的测试先把 letsencrypt.org 固定到 ISRG Root X2,并以 X1 作为备用,然后固定到一个没有任何 CA 为其签发过证书的密钥;服务器迁移到未被固定的 CA 之后,应用看到的正是这种情况:
// OkHttp CertificatePinner against letsencrypt.org: a correct CA-key pin, a backup pin, and a wrong pin.
import java.io.IOException;
import okhttp3.CertificatePinner;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
public class Pin {
static void get(String label, CertificatePinner pinner) {
OkHttpClient client = new OkHttpClient.Builder().certificatePinner(pinner).build();
Request request = new Request.Builder().url("https://letsencrypt.org/robots.txt").build();
try (Response response = client.newCall(request).execute()) {
System.out.println(label + ": HTTP " + response.code());
} catch (IOException e) {
System.out.println(label + ": " + e.getClass().getName() + ": " + e.getMessage());
}
}
public static void main(String[] args) {
// ISRG Root X2 key, plus a backup pin for ISRG Root X1 (RSA): either satisfies the pin set.
get("pinned to ISRG Root X2 (+ X1 backup)", new CertificatePinner.Builder()
.add("letsencrypt.org", "sha256/" + args[0])
.add("letsencrypt.org", "sha256/" + args[1])
.build());
// A key that is not in the chain: what a client sees after the site moves to another CA.
get("pinned to an unrelated key", new CertificatePinner.Builder()
.add("letsencrypt.org", "sha256/" + args[2])
.build());
}
}
pinned to ISRG Root X2 (+ X1 backup): HTTP 200
pinned to an unrelated key: javax.net.ssl.SSLPeerUnverifiedException: Certificate pinning failure!
Peer certificate chain:
sha256/x8ltqh+zNPYG7RZS+IjFymndoy6YAWLPaSkptqebzfc=: CN=letsencrypt.org
sha256/s/tdAOmUzd8syaTuqfgGvFcn6DzA5Cmb+Vby1ST+U3Y=: CN=YE2, O=Let's Encrypt, C=US
sha256/sCkq5UWXjg+7mKu9lMhhYF5bGLsy7VI/UNW3tccdR7w=: CN=Root YE, O=ISRG, C=US
sha256/diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=: CN=ISRG Root X2, O=Internet Security Research Group, C=US
Pinned certificates for letsencrypt.org:
sha256/VIJvW/e8yv/LvjcOADWb4C9aNclxEiQeW0tr4SvazsA=
报错信息列出了 OkHttp 所验证的证书链中各证书的固定值,一直列到信任锚;OkHttp 自己的文档推荐的配置方法正是如此:先在可信网络中使用一个错误的固定值,再从异常中复制预期的值。这些固定值与上文 OpenSSL 的输出一致。同一段文档注释中还有一条警告,值得转告给审批这项变更的人:“未经服务器 TLS 管理员认可,不要使用证书固定!”另外,CertificatePinner 只在常规验证之后运行,因此无法让自签名证书被接受。
我们遇到过一个调试陷阱:容器没有 IPv6 路由,不加 -Djava.net.preferIPv4Stack=true 时,第二次调用报出的是 java.net.SocketException: Network is unreachable。OkHttp 5 会并行尝试多个地址(“快速回退”,fast fallback),IPv4 地址上的证书固定失败最终成了附在 IPv6 连接错误之后的“被抑制”(suppressed)异常;设置 fastFallback(false) 后,证书固定失败才成为主异常。生产环境中证书固定出问题时,也要把 getSuppressed() 记录到日志中。
iOS 和 macOS:NSPinnedDomains
从 iOS 14 和 macOS 11 起,App Transport Security 会固定在 Info.plist 中声明的密钥。Apple 的文章《身份固定》(Identity Pinning)介绍了每个域名的两个列表:NSPinnedCAIdentities(密钥必须出现在链中的某个中间证书或根证书里)和 NSPinnedLeafIdentities(服务器自己的密钥),每个条目都是一个用同一组 OpenSSL 管道命令计算出的 SPKI-SHA256-BASE64 值:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSPinnedDomains</key>
<dict>
<key>letsencrypt.org</key>
<dict>
<key>NSPinnedCAIdentities</key>
<array>
<dict>
<key>SPKI-SHA256-BASE64</key>
<string>diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=</string>
</dict>
</array>
</dict>
</dict>
</dict>
我们在本机(macOS 27.0.1、Apple M4 Max、Swift 6.4)上检查了实际行为:一个用 URLSession 获取 https://letsencrypt.org/robots.txt 的命令行工具,用 swiftc main.swift -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker Info.plist 构建了三次:不带固定值、带上面的 X2 固定值、带 OkHttp 测试中那个无关的密钥:
HTTP 200
HTTP 200
error: NSURLErrorDomain -1200: A TLS error caused the secure connection to fail.
应用收到的是通用的 NSURLErrorSecureConnectionFailed(-1200),而不是证书固定专用的错误码,因此要记录每个应用版本发布时带的是哪一组固定值;否则,服务器变更后出现的固定值不匹配,在崩溃报告中与其他任何 TLS 故障看起来没有区别。NSIncludesSubdomains 只把固定值扩展到下一级子域名(math.example.org,而不是 advanced.math.example.org)。Apple 的建议与 Google 一致:“在大多数情况下,证书固定没有必要,应当避免”;如果要固定,就固定 CA 的密钥,而且不止一个。
轮换固定值而不把任何人挡在门外
固定值集合是对未来的承诺,而持有它的应用可能一年都不会更新。一套可行的轮换流程:
- 在需要之前就发布新的固定值:在某个版本中把下一家 CA 的根证书或下一个密钥加入固定值集合,然后等待,直到不含它的旧版本所占的用户比例低到即使这些用户无法连接,你也能承受。
- 然后才变更服务器:更换 CA 或密钥;只要旧版应用中的固定值包含新证书链中的任一密钥,它们就仍然匹配。
- 在之后的版本中移除已停用的固定值。
- 保留应急通道:一个已固定但从未部署的离线密钥,能让你在紧急情况下从任何 CA 为它申请证书。有些应用还会从经过签名的远程配置中获取固定值更新;这样一来,该配置文件就需要自己的签名密钥,并且要保存在 TLS 基础设施之外。
- 在 Android 上设置到期日期,并把它当作下一次轮换的最后期限,而不是走个形式。
- 测试失败场景:让应用通过一个代理连接,代理使用的证书来自你的信任库但不在固定值集合内,确认应用会拒绝连接(fail closed),并给出有用的提示。
企业的 TLS 检查代理从设计上就会让启用了证书固定的应用无法连接:这些代理的证书链指向一个未被固定的企业根证书。在 Android 上,以 API 级别 24 或更高为目标的应用不信任用户安装的 CA,除非配置中添加了它们;而且只要没有设置 overridePins,固定值对它们仍然适用。
常见错误
- 只固定叶子证书,而所用的 ACME 客户端会轮换密钥。下一次续期就是一次服务中断。
- 只固定一个中间证书,而 CA 在轮换使用多个中间证书。在 CA 换用另一个之前,一切正常。
- 没有备用固定值,或者备用固定值来自同一家 CA。一旦这家 CA 不再受信任,两个固定值会同时失效。
- 固定证书的哈希而不是 SPKI:每次续期都会让它失效,即使密钥不变。
- 手写信任管理器:为了“加上证书固定”而替换平台自带的验证,常常会意外去掉主机名检查或证书链检查。应使用网络安全配置、
CertificatePinner或NSPinnedDomains,它们在常规验证的基础上执行证书固定。 - 指望固定值能防御已被攻破的设备:在已 root 或越狱的设备上,用户(或恶意软件)可以通过打补丁去掉证书固定。证书固定保护的是用户免受网络的威胁,而不是保护你的 API 免受用户的威胁。
参考资料
- RFC 7469(Public Key Pinning Extension for HTTP)和 Chrome 的移除条目
- Android:网络安全配置(固定值、到期日期、覆盖设置、证书透明度)和网络协议安全
- Apple:身份固定:如何为你的应用配置服务器证书和证书透明度策略
- OkHttp
CertificatePinner的源代码和文档注释 - Let's Encrypt:信任链和把证书有效期缩短到 45 天
- CA/Browser Forum《基线要求》2.3.1 版,§6.3.2(有效期)
- Google 安全博客:停止信任 Entrust 证书(2024年6月)