跳到主要内容
ecdsa.com
← 全部文章

2026年的证书固定:何时使用,如何安全地固定

证书固定牺牲了公共 PKI 的灵活性,换来对一种罕见故障的防护:受信任的 CA 为你的域名向他人签发了证书。本文说明在2026年这种取舍要付出什么代价;文中的固定值根据一条真实证书链计算,并在 OkHttp 5.5.0 以及 macOS 27 上 Apple 的 App Transport Security 中做了测试。

简短回答:浏览器已经放弃了基于 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):

服务器发送的每张证书的 SPKI 固定值
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 中得到的固定值如下:

根证书固定值(对 /etc/ssl/certs/*.pem 运行同一组 openssl 管道命令)
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 引用(文档):

res/xml/network_security_config.xml:当前 CA 的根证书加上第二家 CA,并设置到期日期
<?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 之后,应用看到的正是这种情况:

Pin.java (OkHttp 5.5.0, Okio 3.18.1, Kotlin stdlib 2.1.21, Java 25)
// 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());
    }
}
java -Djava.net.preferIPv4Stack=true -cp okhttp-jvm-5.5.0.jar:okio-jvm-3.18.1.jar:kotlin-stdlib-2.1.21.jar Pin.java …
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 值:

Info.plist(节选):把 letsencrypt.org 固定到 ISRG Root X2 的密钥
<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 的密钥,而且不止一个。

轮换固定值而不把任何人挡在门外

固定值集合是对未来的承诺,而持有它的应用可能一年都不会更新。一套可行的轮换流程:

  1. 在需要之前就发布新的固定值:在某个版本中把下一家 CA 的根证书或下一个密钥加入固定值集合,然后等待,直到不含它的旧版本所占的用户比例低到即使这些用户无法连接,你也能承受。
  2. 然后才变更服务器:更换 CA 或密钥;只要旧版应用中的固定值包含新证书链中的任一密钥,它们就仍然匹配。
  3. 在之后的版本中移除已停用的固定值。
  4. 保留应急通道:一个已固定但从未部署的离线密钥,能让你在紧急情况下从任何 CA 为它申请证书。有些应用还会从经过签名的远程配置中获取固定值更新;这样一来,该配置文件就需要自己的签名密钥,并且要保存在 TLS 基础设施之外。
  5. 在 Android 上设置到期日期,并把它当作下一次轮换的最后期限,而不是走个形式。
  6. 测试失败场景:让应用通过一个代理连接,代理使用的证书来自你的信任库但不在固定值集合内,确认应用会拒绝连接(fail closed),并给出有用的提示。

企业的 TLS 检查代理从设计上就会让启用了证书固定的应用无法连接:这些代理的证书链指向一个未被固定的企业根证书。在 Android 上,以 API 级别 24 或更高为目标的应用不信任用户安装的 CA,除非配置中添加了它们;而且只要没有设置 overridePins,固定值对它们仍然适用。

常见错误

  • 只固定叶子证书,而所用的 ACME 客户端会轮换密钥。下一次续期就是一次服务中断。
  • 只固定一个中间证书,而 CA 在轮换使用多个中间证书。在 CA 换用另一个之前,一切正常。
  • 没有备用固定值,或者备用固定值来自同一家 CA。一旦这家 CA 不再受信任,两个固定值会同时失效。
  • 固定证书的哈希而不是 SPKI:每次续期都会让它失效,即使密钥不变。
  • 手写信任管理器:为了“加上证书固定”而替换平台自带的验证,常常会意外去掉主机名检查或证书链检查。应使用网络安全配置、CertificatePinner 或 NSPinnedDomains,它们在常规验证的基础上执行证书固定。
  • 指望固定值能防御已被攻破的设备:在已 root 或越狱的设备上,用户(或恶意软件)可以通过打补丁去掉证书固定。证书固定保护的是用户免受网络的威胁,而不是保护你的 API 免受用户的威胁。

参考资料