在手机上装一个调试代理的根证书,再给它打开完全信任,这个代理就能读到设备上几乎所有 App 的 HTTPS 流量。什么都不会坏:手机每请求一个站点,代理就当场做一张证书,用手机现在信任的那个根签名,手机的检查全部通过。只有少数 App 直接拒绝连接。它们被写成只信任一小串密钥,而不是设备上的每一个根。这就是证书固定。

这篇文章站在你自己发布的 App 这一边讲证书固定。我搭了一套小型公钥基础设施,里面有两家公共 CA 和一个拦截用的根,给同一个主机名签了九张证书,从一次例行续签,到被 App 钉住的那家 CA 自己签出的冒牌证书,然后让每张证书逐一对上四种钉法:一次用 curl,一次用跑在 iOS 26.5 模拟器上的 iOS App,模拟器信任全部三个根。结果放得进一张表,产生结果的每个脚本都附在文末。

证书固定的名声有点奇怪。Apple 的指南写着「大多数情况下,固定没有必要,应当避免」,1 Android 的指南写着它「不推荐用于 Android App」。2 对大多数 App 来说两边都对,原因在那张表里一目了然:每一种钉法都会在某个事件上失效,而这个事件常常不在你的掌控之中。所以真正有用的问题,是你在承诺明年还握着哪一把密钥,以及万一你错了,你的用户会怎样。

信任库替你担保了什么

App 用平台默认设置建立 TLS 连接时,服务器发来一条证书链,客户端检查这条链是否终止于系统信任库里的某个根,叶子证书是否写着这个主机名,每张证书是否在有效期内、是否被允许做它正在做的事。信任库里的根来自许多机构,其中任何一家都可以给任何名字签证书。正是这种宽度,让一部手机能和整个 Web 上的站点通信;也正因如此,手机同时信任所有这些机构,而且是对每一个名字都信任。

一条链可以通过上面每一项检查,却仍然带着一把不属于你的密钥,有两种途径。第一种,是某家 CA 把你的名字签给了别人。2011 年,荷兰 CA DigiNotar 被入侵,签发了 Google 域名的证书,这些证书被用来攻击伊朗的 Gmail 用户;事后的调查认定约 30 万人是主要目标,DigiNotar 当年 9 月宣告破产。3 伪造的证书之所以被发现,是因为 Chrome 为 Google 自己的站点内置了 pin,这是证书固定第一次大规模证明自己的用处。今年 10 月,eMudhra 的八把中间 CA 私钥被发现都是小于 256 的整数,任何人都能算出它们,签出所有主流平台都会接受的证书。装得进一个字节的八把 CA 私钥 讲的就是这件事。

第二种,是设备上有一个不是你选的根。调试代理、企业 TLS 检查和一些恶意软件,都是靠把自己的根塞进信任库来工作的。平台已经把这件事变难了。在 iOS 上,通过描述文件安装的根「不会自动被信任用于 SSL」:设备主人必须到「设置 > 通用 > 关于本机 > 证书信任设置」里为它打开完全信任,不过通过设备管理安装的根不需要这一步就会被信任。4 在 Android 上,只有目标版本为 Android 6.0(API 23)及以下的 App 默认信任用户添加的 CA,目标为 API 24 及以上的 App 除非主动选择,否则会忽略它们。5

针对第一种情况,Web 有证书透明度(Certificate Transparency)。Chrome 要求 2018 年 4 月 30 日之后签发的公共信任证书必须登记到日志里,Apple 对 2018 年 10 月 15 日之后签发的证书也有同样要求,6 所以一张冒用你名字的证书必须出现在公开日志里,你或者监控服务能在那里找到它。透明度让误签在事后可见,却不能阻止客户端在证书递过来的那一刻接受它,而且对设备主人自己装的根完全不起作用。

pin 在连接发生的那一刻、在你自己的 App 里,同时覆盖这两种情况。这是它买到的东西,而它的每一项代价,也都出自同一个性质。

钉住的到底是什么

证书把一把公钥包在名字、日期、扩展和 CA 的签名里。证书每次续签都会变,里面的密钥却不必变。所以 pin 不是证书的哈希,而是密钥的哈希,取的是证书里携带它的那种形式:SubjectPublicKeyInfo 结构的 DER 编码,里面装着算法和密钥本身,做 SHA-256 后写成 Base64。Apple、Android 和 curl 都这样给 pin 命名。

OpenSSL 能从任意一张证书算出它:

openssl x509 -in cert.pem -pubkey -noout \
  | openssl pkey -pubin -outform der \
  | openssl dgst -sha256 -binary \
  | openssl enc -base64

下面是本站在 2026 年 10 月 24 日返回的证书链,以及每张证书的 pin:

证书签发者pin
www.roobli.org,有效期到 2026 年 12 月 10 日Google Trust Services WE1qz862ofAri0qIdcw7UjgQOD6cAL3pX3a2CvzuWdL/pQ=
Google Trust Services WE1GTS Root R4kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=
GTS Root R4GlobalSign Root CA(交叉签名)mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=

这里面没有一个是我能负责任地钉住的。本站在 Cloudflare 后面,叶子证书的私钥由 Cloudflare 持有,续签也按它自己的节奏走。Cloudflare 的文档说,这类证书的「有效期和证书颁发机构都由 Cloudflare 控制」,可能来自 Let’s Encrypt、Google Trust Services 或 SSL.com,7 如果钉住,「每次证书轮换,你的域名都会下线」。对必须钉住的人,它的建议是上传自己的证书,并且有意识地复用密钥。8 pin 点名的是一把密钥,而你只能为自己握着的密钥做承诺。

flowchart TB
  R["根 CA<br/>自签名,在信任库里"] -->|签发| I["签发 CA<br/>钉 CA 可以点名这把密钥<br/>或者根的那把"]
  I -->|签发| L["叶子证书<br/>服务器自己的密钥<br/>钉叶子点名这把密钥"]
  B["备用密钥<br/>已生成、离线保管<br/>还不在任何证书里"] -.->|一起钉上| L

pin 能指向的地方:链上任意一层 CA 的密钥,或者服务器自己的密钥,外加一把预留的密钥,留到第一把必须退役的那一天。

九张证书,四种钉法

实验里有五个机构。Public Root A 和 Public Root B 各自签发一个签发 CA,A1 和 B1,代表两家普通的公共 CA。Inspection Root 代表代理的根,像这类代理那样直接签发叶子证书。客户端信任全部三个根,这就是一部手机的主人装了代理根并打开信任之后的处境,也正是 pin 要对付的情形。

每张服务器证书都签给 pinlab.localhost 和 *.pinlab.localhost,这两个名字解析到回环地址。九个服务器跑在九个端口上:

场景签发者密钥代表什么
正常A1K1今天的服务器
续签A1K1同一把密钥的一张新证书
计划内轮换A1备用换到 App 早已作为备用钉住的那把密钥
计划外换钥A1K2一次悄悄生成了新密钥的续签
换 CAB1K1同一把密钥,由另一家公共 CA 签发
过期A1K1密钥对,证书已过期
拦截Inspection Root代理的密钥代理当场做出来的证书
冒牌,另一家 CAB1攻击者的密钥一家公共 CA 把你的名字签给了别人
冒牌,被钉的 CAA1攻击者的密钥同上,但出自 App 钉住的那家 CA

客户端用四种方式连接每个服务器。不钉,就依赖信任库。钉 CA,要求链上某处出现 A1 的密钥。钉叶子,要求服务器自己的证书里是 K1 或备用密钥。代码里钉,由一个 URLSession 委托在系统评估完证书链之后,把叶子和同样两把密钥比对。在 iOS 上,前三种是 Info.plist 里的设置,第四种是四十来行 Swift;curl 的 --pinnedpubkey 给钉叶子提供了第二个独立实现。

场景不钉钉 CA(A1)钉叶子(K1 加备用)代码里钉
正常能连上能连上能连上能连上
续签能连上能连上能连上能连上
计划内轮换能连上能连上能连上能连上
计划外换钥能连上能连上被拒绝被拒绝
换 CA能连上被拒绝能连上能连上
过期被拒绝被拒绝被拒绝被拒绝
拦截能连上被拒绝被拒绝被拒绝
冒牌,另一家 CA能连上被拒绝被拒绝被拒绝
冒牌,被钉的 CA能连上能连上被拒绝被拒绝

iOS 26.5 模拟器和 curl 8.7.1,2026 年 10 月 24 日。curl 只能钉叶子,它在每一行都与钉叶子一列一致:密钥不匹配任何 pin 时报错误 90,证书过期时报错误 60。

按列读这张表。不钉的时候,App 接受了代理和两张冒牌证书,只有过期那张失败,而且它在每一列都失败,因为 pin 是在常规检查之上额外检查,而不是取代常规检查。Apple 的文档也这么说:「为某个域名固定证书,不影响其他任何安全要求。」9

钉 CA 挡住了代理和另一家 CA 的冒牌证书,服务器想换几次密钥都行。它会在服务器的证书改由另一家 CA 签发的那天失效,也就是表里被拒绝的「换 CA」。它还接受了被钉 CA 自己签出的冒牌证书,因为那张证书恰好带着 pin 点名的那把密钥。钉 CA,等于押注你钉住的那家 CA 永远不会犯 eMudhra 犯过的错。

钉叶子,以及在代码里执行同一条规则,挡住了代理和每一张冒牌证书,扛过了续签、计划内轮换和换 CA,在计划外换钥时失效。它只信任服务器自己的密钥,不信任任何别人的,所以只会在服务器密钥毫无预告地变了的时候失效。

每种钉法都在不同的事件上失效,所以在它们之间做选择,其实是在问:每个事件由谁引起。

事件由谁决定对 pin 的影响
续签时生成新密钥你的 ACME 客户端默认设置,或者运维服务器的人钉叶子失效
换到另一家 CA你、你的 CDN,或者 CA 退役某个中间证书钉 CA 失效
轮换到备用密钥你没有影响,前提是备用密钥已经钉上
被钉的 CA 误签那家 CA钉 CA 照样放行

最安全的 pin,钉在这样一把密钥上:它的下一次更换由你自己来做,时间由你挑,换上的是 App 早已认识的那一把。

证书越来越短

如果续签很少发生,这一切还算好管理。但续签正在变得频繁。2025 年 4 月,CA/浏览器论坛通过了 SC-081 号投票,逐步缩短公共信任的 TLS 证书最长有效期:10

签发日期最长有效期
2026 年 3 月 15 日之前398 天
2026 年 3 月 15 日起200 天
2027 年 3 月 15 日起100 天
2029 年 3 月 15 日起47 天

CA 可以复用域名验证结果的时长,也在同样的日期缩短,最后降到 10 天。Let’s Encrypt 的证书本来就是 90 天,它计划在 2027 年 2 月把默认有效期改成 64 天,2028 年 2 月改成 45 天,并且从 2026 年 1 月起提供只有 160 小时的证书。11

每一次续签,都是密钥可能改变的一次机会。最常用的 ACME 客户端 Certbot,除非另行指定,否则每次续签都会生成新私钥:「默认行为是不复用私钥」,而 --reuse-key 会保留现有的那一把。12 一张每 47 天按默认设置续签一次的证书,钉它的叶子,第一次续签就会失效,因为那次续签带来的是一把新密钥。

CA 不是你的时候,钉它的中间证书也逃不开这个问题。Let’s Encrypt 现在用一组它称为 Generation Y 的中间证书签发,宣布时它说:「每次签发都会随机选择使用哪个中间证书,以阻止对中间证书密钥的固定。」它的备用中间证书「随时可能开始签发,不另行通知」。13 钉其中一个,就是在猜下一张证书会出自哪一个。

证书变短,改变的是什么值得钉:钉密钥而不是证书,钉你掌控的密钥而不是 CA 会轮换的密钥。它也意味着要把 API 背后的那把密钥当成一份长期保管、有自己轮换计划的机密,而不是续签顺带产生的东西。

在 iOS 上声明式钉住

从 iOS 14 和 macOS 11 开始,App Transport Security 会从 Info.plist 的 NSPinnedDomains 读取 pin。每一项点名一个域名,里面放 NSPinnedCAIdentities、NSPinnedLeafIdentities 或两者都放,每个都是一组字典,字典只有一个键 SPKI-SHA256-BASE64。CA 身份匹配的是「服务器提供的证书链中至少一张 CA 或子 CA 证书」,根也算在内;叶子身份匹配服务器自己的证书。一组里命中一个就够,备用密钥就放在这里;两组都写时,ATS 要求每组各命中一个。NSIncludesSubdomains 默认关闭,打开后也只多覆盖一层标签:覆盖 api.example.com,不覆盖 v2.api.example.com。9

实验里的 App 是这样钉住其中两个主机的:

<key>NSAppTransportSecurity</key>
<dict>
  <key>NSPinnedDomains</key>
  <dict>
    <key>ca.pinlab.localhost</key>
    <dict>
      <key>NSPinnedCAIdentities</key>
      <array>
        <dict><key>SPKI-SHA256-BASE64</key><string>OoMk4pryqH5220TQ2FmKeRHm3ev5pXOus9f6GEmJ0lg=</string></dict>
      </array>
    </dict>
    <key>leaf.pinlab.localhost</key>
    <dict>
      <key>NSPinnedLeafIdentities</key>
      <array>
        <dict><key>SPKI-SHA256-BASE64</key><string>U5PGzZgeovRE26eMZw4xCRtJ0s5LrULkQbYioKNo8ZI=</string></dict>
        <dict><key>SPKI-SHA256-BASE64</key><string>8pIhoNh8TtXMCrNsIUGhZf6lkNkgc/VGQSXs4OQspV0=</string></dict>
      </array>
    </dict>
  </dict>
</dict>

第一个值是 A1 的 pin,第二个是 K1 的,第三个是备用密钥的;你自己跑实验会生成不同的密钥和不同的值。ATS 对这些 .localhost 名字照样执行了 pin,它们是完整的限定名,只有被钉住的主机拒绝了代理。被拒绝的连接到 App 手里是 NSURLErrorDomain 的 -1200 错误,即安全连接失败,流错误是 -9802,App 无法绕过。用一位 Apple 工程师在开发者论坛上的话说:「如果连接被 ATS 拦下,就没有办法继续。」14

Apple 关于证书固定的文章建议钉 CA 的密钥而不是服务器的密钥,钉不止一把密钥,并且为 pin 失效的那天准备好降级体验。1 第一条是否明智,取决于那是谁的 CA,上一节已经说过;后两条没有商量余地。还要留意这种声明覆盖了什么。在 2021 年的一个论坛帖子里,Apple 员工确认 URLSession 遵守 NSPinnedDomains,但没能让 WKWebView 遵守,15 所以加载你 API 的网页视图需要单独检查,App 里自带 TLS 实现的库也一样。

用代码钉住,以及两个看起来很像的 bug

声明式不够用的时候,就该用代码:带过期日期的 pin、失败上报、和其他平台共用的网络层。下面这个委托是实验里正确的版本。它先让系统评估证书链,再把叶子的密钥和 pin 比对。

func urlSession(
  _ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
  completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
  guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
        let trust = challenge.protectionSpace.serverTrust else {
    return completionHandler(.performDefaultHandling, nil)
  }
  // The system's own checks first: chain, name, dates.
  guard SecTrustEvaluateWithError(trust, nil),
        let leaf = (SecTrustCopyCertificateChain(trust) as? [SecCertificate])?.first,
        let key = SecCertificateCopyKey(leaf),
        let pin = spkiPin(of: key),
        pins.contains(pin) else {
    return completionHandler(.cancelAuthenticationChallenge, nil)
  }
  completionHandler(.useCredential, URLCredential(trust: trust))
}

第一个 bug 藏在 spkiPin 里。SecKeyCopyExternalRepresentation 返回的是裸密钥,P-256 密钥就是那个点的 65 个字节。pin 是对包住这些字节的 SubjectPublicKeyInfo 结构做的哈希,所以代码必须在哈希前把这个结构的头部补回去。头部只取决于密钥的类型和长度:

let spkiHeaders: [String: [UInt8]] = [
  "EC-256": [0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01,
             0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00],
  "EC-384": [0x30, 0x76, 0x30, 0x10, 0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01,
             0x06, 0x05, 0x2B, 0x81, 0x04, 0x00, 0x22, 0x03, 0x62, 0x00],
  "RSA-2048": [0x30, 0x82, 0x01, 0x22, 0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7,
               0x0D, 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, 0x82, 0x01, 0x0F, 0x00],
]
 
func spkiPin(of key: SecKey) -> String? {
  guard let kind = keyKind(key), let header = spkiHeaders[kind],
        let raw = SecKeyCopyExternalRepresentation(key, nil) as Data? else { return nil }
  return Data(SHA256.hash(data: header + [UInt8](raw))).base64EncodedString()
}

漏掉头部,代码哈希出来的东西任何别的工具都算不出来。实验里,这个版本给 K1 算出的是 SjXCOVl4c+hrCGRMJOBelLidisx0oa4Giv+4DLWDNAo=,而 K1 真正的 pin 是 U5PGzZgeovRE26eMZw4xCRtJ0s5LrULkQbYioKNo8ZI=,于是九个服务器全部被拒绝,正常的那个也不例外。这个 bug 很吵:App 连不上自己的服务器,第一次测试就会发现。

第二个 bug 很安静。一个只比对 pin、从不调用 SecTrustEvaluateWithError 的委托,会接受任何一条叶子带着被钉密钥的链:过期的证书、写着别的名字的证书、终止于不受信任的根的链。这是各种指南警告得最多的 bug,可在 iOS 上,实验里它没有露面。ATS 保持默认时,这个委托依然以 -1200 拒绝了过期证书;一个看都不看就接受所有证书的委托也一样。在列进 NSPinnedDomains 的主机上,连这个盲目接受的委托也过不了 pin:代理、换 CA 和另一家 CA 的冒牌证书都被拒绝,和只靠声明时完全一样。

资料在这一点上说法不一。Apple 的文档说:「对受 ATS 保护的域名,你不能放宽服务器信任要求,但可以收紧。」16 OWASP 的测试指南则说,接管了判断的委托最终会「完全绕过默认的 App Transport Security(ATS)检查」。17 在模拟器上,两种说法都成立,只是针对不同的域名。我给第五个主机加了一条 ATS 例外 NSExceptionAllowsInsecureHTTPLoads,就是人们为了连上带自签名证书的测试服务器而加的那一条。在这个主机上,跳过评估的委托接受了过期证书,盲目接受的委托接受了一切,连代理也包括在内。

在 iOS 上,这个安静的 bug 是在潜伏,而不是不存在。它在等一次对 Info.plist 的修改,而改的人想不到这和证书固定的代码有关。在没有 ATS 的平台上,委托的判断就是唯一的判断,这个 bug 从一开始就是活的。不管平台今天碰巧替你做了什么,每一次都先评估。

如果你宁愿完全不写这些,TrustKit 在 iOS 上封装了同样的逻辑。它仍在维护(3.0.8 版发布于 2026 年 10 月),会拒绝没有备用 pin 的配置,还能把 pin 失败上报给你指定的服务器。18

Android 简述

Android 在网络安全配置里声明 pin,这是一个由清单文件指向的 XML 文件:

<network-security-config>
  <domain-config>
    <domain includeSubdomains="true">api.example.com</domain>
    <pin-set expiration="2027-06-30">
      <pin digest="SHA-256">U5PGzZgeovRE26eMZw4xCRtJ0s5LrULkQbYioKNo8ZI=</pin>
      <pin digest="SHA-256">8pIhoNh8TtXMCrNsIUGhZf6lkNkgc/VGQSXs4OQspV0=</pin>
    </pin-set>
  </domain-config>
</network-security-config>

证书链只有在任意一层包含至少一把被钉的密钥时才有效,文档写着「你应当始终包含一把备用密钥」。expiration 属性是 iOS 没有的功能:过了这个日期,这个域名的固定就会关闭,于是一个从未更新的 App 会退回普通校验,而不是直接失败;文档也提醒,过期之后这可能让攻击者绕过 pin,这正是这个属性所做的取舍。5

用户添加的 CA 过不了 pin。信任锚可以带上 overridePins="true",对这些 CA 签出的链跳过固定,但它的默认值处处都是 false,只有在 debug-overrides 里例外,而 debug-overrides 只对可调试的构建生效。再加上目标为 API 24 及以上的 App 默认根本不信任用户添加的 CA,在 iOS 上促使大多数人去做证书固定的那种随手装上的代理根,在 Android 上本来就被挡在门外了。Android 16 加入了 App 可以选择开启的证书透明度检查,Android 17 把它默认打开;使用自定义信任锚的连接不做这项检查。5

平台的立场和 Apple 一致:证书固定「不推荐用于 Android App」;如果还是要钉,建议是「多个备用 pin,其中至少一把完全由你掌控」。2

Web 试过钉住,后来放弃了

浏览器曾经让任何站点给自己设 pin。HTTP 公钥固定(HPKP)是 2015 年 4 月的 RFC 7469,19 站点发一个响应头,写明自己的密钥和一个有效期,浏览器在这段时间里拒绝任何别的密钥。这是由服务器设定、而不是随代码发布的 pin,它失败的方式和 pin 一贯的失败方式一模一样。

2016 年 10 月,Smashing Magazine 续签证书时换了新密钥,而它的 HPKP 头 max-age=31536000; includeSubDomains 早已告诉回访者的浏览器,一年之内只接受旧密钥。10 月 21 日到 25 日之间,大多数访客都打不开这个站。把新密钥加进响应头也没用,因为存了旧 pin 的浏览器根本走不到读取新响应头那一步;想退回去也不行,因为旧证书已经过期。团队写的复盘标题是「Be Afraid Of HTTP Public Key Pinning (HPKP)」。20

另一个危险是恶意固定:攻击者哪怕只短暂控制了某个站点的服务器,也能钉上自己的密钥,在响应头写的那段时间里把真正的主人锁在门外。Chrome 在 2018 年的 67 版弃用了 HPKP,2019 年 1 月的 72 版将其移除,理由是「采用率极低」以及「拒绝服务和恶意固定的风险」。21 Firefox 在 2020 年 1 月的 72 版移除了它。22 Web 转而依靠证书透明度,到 2022 年,Chrome 连 Expect-CT 头也弃用了,因为那时它已经要求每个公共站点都满足透明度要求。23

App 躲开了这个故事里「恶意」的那一半,因为它的 pin 随你签名的代码发布,而不是写在入侵者能改写的响应头里。另一半它躲不开:已安装 App 里的 pin 一直有效,直到用户更新,而有些用户永远不更新。Smashing Magazine 的事故来自一个误设的一年期 max-age;一个 App 的 pin,max-age 就是它最老的那个已安装版本还在被使用的时长。

轮换密钥,不把任何人锁在门外

表里「计划内轮换」那一行,就是整个流程的缩影:App 钉了 K1 和一把备用密钥,服务器换到备用密钥,所有客户端都连上了。用你自己握着的密钥,把它写成步骤:

  1. 在需要之前就做好备用密钥。 在服务器以外的机器上生成它,像保管任何签名密钥一样离线保存,把它的 pin 和当前的 pin 一起放进 App。它此时还不出现在任何证书里。
  2. 两个 pin 一起发布,然后等待。 只钉了当前密钥的版本,是你永远无法轮换走的版本。让带着两个 pin 的版本覆盖到你想留住的用户。
  3. 轮换到备用密钥。 从任意一家 CA 为备用密钥申请证书;钉叶子不在乎是哪家,「换 CA」那一行已经说明了。每个带着备用 pin 的版本都继续正常工作。
  4. 做好下一把备用密钥并发布。 新的一对是正在服役的密钥和一把新的备用。新版本不再钉旧密钥,你可以在自己选定的时候把它从服务器上撤下。
  5. 决定掉队的版本怎么办。 比第 2 步更老的版本只有旧 pin。要么为它们保留一台使用旧密钥的服务器,要么从一个不在它们 pin 覆盖范围内的主机,给它们展示更新提示。

还有三个习惯,能让这个计划扛得住。把续签设置写下来:用 certbot,就是在这把密钥的整个寿命里都加 --reuse-key,再在部署时检查正在提供服务的密钥仍然匹配某个 pin。给 pin 设一个终点:在 Android 上是 expiration 属性,而 NSPinnedDomains 没有过期设置,所以在 iOS 上,带截止日期的 pin 只能写在代码里,一个落后好几年的版本应当退回普通校验,而不是什么都连不上。两头都要盯着:像 TrustKit 那样,把 pin 失败上报到 pin 覆盖范围之外的端点,并且用证书透明度监控以你的名字签发的证书,这也是 Cloudflare 推荐用来替代证书固定的做法。8

如果为了不发版就能改 pin 而远程下发 pin 列表,就用一把内置在 App 里的密钥给列表签名,并且从一个不依赖这份列表里 pin 的主机去取。否则,pin 把用户锁在门外的那一天,也就是他们取不到替换列表的那一天。

钉住做不到的事

pin 保护你的用户不受第三方伤害,却不保护你的 App 不受它自己用户的伤害。在越狱或 root 过的设备上,或者在被重新打包的 App 里,固定会被拆掉:SSL Kill Switch 3 挂钩系统的信任评估函数,objection 把绕过固定列为它的功能之一,Frida 也有现成的脚本做这件事。24 如果你的服务器必须确认对面是你的正版 App,那是另一个问题,要用另一套工具:iOS 上是 App Attest,「一项用来验证设备上运行的你的 App 实例的服务」,25 Android 上是 Play Integrity,两者都在服务器端校验。

服务器自己被攻破时,pin 帮不上忙,因为攻击者那时就握着被钉的密钥。被钉的 CA 正是出事的那一家时,钉 CA 也帮不上忙,表的最后一行已经说明。它只覆盖经过执行它的那套网络实现的连接。它还有实实在在的代价:设备的主人,以及研究 App 的人,都看不到 App 发出了什么。OWASP 的移动应用标准把证书固定列为更高保障级别的控制项,即 MASVS-NETWORK-2:「App 对开发者掌控的所有远程端点执行身份固定。」26 它的弱点目录也说得同样直白:「固定并非万无一失。」

你的 App 该不该钉?

下面这些条件都满足时再钉:App 连接的是你自己运营的主机;你持有,或者将会持有,你要钉的那些密钥;你能发布备用 pin,并照上面的轮换计划执行;这条连接上的数据值得冒把一部分用户锁在门外的风险,银行、健康记录和让人登录的令牌都属于这一类。下面这些情况不要钉:CDN 或平台替你选密钥和 CA,本站就属于这种;没有人会负责轮换计划;或者你唯一担心的是 Android 上的代理根,而现代 App 本来就会忽略它。

如果要钉一个我自己的 API,我会钉服务器的密钥:一把当前密钥加一把离线的备用,并在续签时配置好复用密钥。钉 CA 我只会作为后备,只钉和我签了合同、承诺保留其中间证书的 CA,而且要钉就钉两家,这样其中一家变了也不会造成事故。pin 是一份关于明年那把密钥的承诺。让它说的是一把你到时还握在手里的密钥。

自己动手

上面的一切都来自下面这些脚本,运行环境是 macOS 26.5、Xcode 26.5、OpenSSL 4.0.3 和系统自带的 curl 8.7.1。每次运行都会重新生成密钥,所以你的 pin 会和我的不同,结果的规律不会。建一个空文件夹,把脚本放进去。

线上站点的 pin

pin.sh 打印一个密钥文件、一个证书文件,或者一台线上服务器所提供的叶子证书的 pin。

#!/bin/sh
# Prints the SPKI pin of a key file, a certificate file, or (with -c host:port)
# the leaf a live server presents: base64(sha256(DER SubjectPublicKeyInfo)).
set -eu
case "$1" in
  -c) openssl s_client -connect "$2" -servername "${2%:*}" </dev/null 2>/dev/null | openssl x509 -pubkey -noout ;;
  *.key) openssl pkey -in "$1" -pubout ;;
  *) openssl x509 -in "$1" -pubkey -noout ;;
esac | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64
chmod +x pin.sh
./pin.sh -c www.roobli.org:443

九张证书

make-certs.sh 在 pki/ 里生成两家公共 CA、拦截用的根、五把密钥和九张服务器证书。过期的那张证书用到了 -not_before 和 -not_after;如果你的 OpenSSL 没有这两个选项,删掉那一行即可。

脚本:保存为 make-certs.sh
#!/bin/sh
# Builds the pinning lab's PKI: two public CAs, an interception root, and
# server certificates for *.pinlab.localhost under eight scenarios.
set -eu
cd "$(dirname "$0")" && mkdir -p pki && cd pki
 
key() { openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$1.key" 2>/dev/null; }
 
root() { # name, subject
  key "$1"
  openssl req -x509 -new -key "$1.key" -subj "/CN=$2" -days 3650 -out "$1.pem" \
    -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign"
}
 
ca() { # name, subject, parent
  key "$1"
  openssl req -new -key "$1.key" -subj "/CN=$2" -out "$1.csr"
  openssl x509 -req -in "$1.csr" -CA "$3.pem" -CAkey "$3.key" -days 1825 -out "$1.pem" \
    -extfile /dev/stdin <<X
basicConstraints=critical,CA:TRUE,pathlen:0
keyUsage=critical,keyCertSign,cRLSign
X
}
 
leaf() { # name, key, issuer, [extra x509 options]
  name=$1 k=$2 issuer=$3; shift 3
  openssl req -new -key "$k.key" -subj "/CN=pinlab.localhost" -out "$name.csr"
  openssl x509 -req -in "$name.csr" -CA "$issuer.pem" -CAkey "$issuer.key" "$@" -out "$name.pem" \
    -extfile /dev/stdin <<X
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature
extendedKeyUsage=serverAuth
subjectAltName=DNS:pinlab.localhost,DNS:*.pinlab.localhost
X
}
 
root  public-a-root  "Pinlab Public Root A"
ca    public-a-ca    "Pinlab Public CA A1" public-a-root
root  public-b-root  "Pinlab Public Root B"
ca    public-b-ca    "Pinlab Public CA B1" public-b-root
root  inspect-root   "Pinlab Inspection Root"
 
key k1; key k2; key backup; key proxy; key rogue
 
leaf legit    k1     public-a-ca -days 30
leaf renewed  k1     public-a-ca -days 30
leaf rekeyed  k2     public-a-ca -days 30
leaf rotated  backup public-a-ca -days 30
leaf expired  k1     public-a-ca -not_before 20250101000000Z -not_after 20250201000000Z
leaf switched k1     public-b-ca -days 30
leaf misissue rogue  public-b-ca -days 30
leaf sameca   rogue  public-a-ca -days 30
leaf proxied  proxy  inspect-root -days 30
 
cat public-a-root.pem public-b-root.pem inspect-root.pem > trusted-roots.pem
rm -f ./*.csr ./*.srl

九个服务器

serve.sh 在回环地址上为每个场景启动一个 openssl s_server,./serve.sh stop 把它们全部结束。

脚本:保存为 serve.sh
#!/bin/sh
# Starts one TLS server per scenario on 127.0.0.1. ./serve.sh stop ends them.
set -eu
cd "$(dirname "$0")/pki"
if [ "${1:-}" = stop ]; then
  [ -f ../servers.pid ] && kill $(cat ../servers.pid) 2>/dev/null; rm -f ../servers.pid; exit 0
fi
: > ../servers.pid
serve() { # port, certificate, key, [chain]
  set -- "$1" "$2" "$3" ${4:+-cert_chain "$4"}
  port=$1 cert=$2 k=$3; shift 3
  openssl s_server -quiet -www -accept "127.0.0.1:$port" -cert "$cert.pem" -key "$k.key" "$@" \
    >/dev/null 2>&1 &
  echo $! >> ../servers.pid
}
serve 8441 legit    k1     public-a-ca.pem
serve 8442 proxied  proxy
serve 8443 renewed  k1     public-a-ca.pem
serve 8444 rekeyed  k2     public-a-ca.pem
serve 8445 rotated  backup public-a-ca.pem
serve 8446 expired  k1     public-a-ca.pem
serve 8447 switched k1     public-b-ca.pem
serve 8448 misissue rogue  public-b-ca.pem
serve 8449 sameca   rogue  public-a-ca.pem
sleep 1

用 curl

curl-lab.sh 对每个服务器连接两次:一次信任三个根,一次在此基础上再钉住 K1,并把备用密钥作为第二个 pin。

脚本:保存为 curl-lab.sh
#!/bin/sh
# Connects to each scenario twice: trusting all three roots (a device whose
# store includes an inspection root), then also pinning the leaf key K1 with
# the pre-made backup key as a second pin.
cd "$(dirname "$0")"
pins="sha256//$(./pin.sh pki/k1.key);sha256//$(./pin.sh pki/backup.key)"
printf "%-10s %-22s %s\n" scenario "trust store only" "store + pins"
for s in 8441:legit 8442:proxied 8443:renewed 8444:rekeyed 8445:rotated 8446:expired 8447:switched 8448:misissue 8449:sameca; do
  url="https://pinlab.localhost:${s%%:*}/"
  run() { curl -s -o /dev/null --cacert pki/trusted-roots.pem "$@" "$url" && echo accepted || echo "refused (curl $?)"; }
  printf "%-10s %-22s %s\n" "${s#*:}" "$(run)" "$(run --pinnedpubkey "$pins")"
done
chmod +x make-certs.sh serve.sh curl-lab.sh
./make-certs.sh && ./serve.sh && ./curl-lab.sh
scenario   trust store only       store + pins
legit      accepted               accepted
proxied    accepted               refused (curl 90)
renewed    accepted               accepted
rekeyed    accepted               refused (curl 90)
rotated    accepted               accepted
expired    refused (curl 60)      refused (curl 60)
switched   accepted               accepted
misissue   accepted               refused (curl 90)
sameca     accepted               refused (curl 90)

在 iOS 模拟器上

这个 App 在每种策略下连接每个场景,每个场景打印一行。除了表里的四种钉法,它还跑了两个有 bug 的委托(noHeader、noEvaluation),在被钉主机上跑了盲目接受的委托(blind),又在一个带 ATS 例外的主机上把后两个各跑了一遍(looseNoEval、looseBlind)。把它保存为 app/main.swift。

App:保存为 app/main.swift
// PinLab: connects to every lab scenario under every pinning policy and
// prints one row per scenario. Run it in a simulator that trusts the lab's
// three roots; ./run-ios.sh does that.
 
import CryptoKit
import Foundation
import Security
import UIKit
 
let scenarios: [(port: Int, name: String)] = [
  (8441, "legit"), (8442, "proxied"), (8443, "renewed"), (8444, "rekeyed"),
  (8445, "rotated"), (8446, "expired"), (8447, "switched"), (8448, "misissue"), (8449, "sameca"),
]
 
// The pins the code-based policies accept, read from Info.plist so the
// declarative and the code policies share one source.
let codePins = Bundle.main.object(forInfoDictionaryKey: "PinLabLeafPins") as? [String] ?? []
 
// SubjectPublicKeyInfo headers: SecKeyCopyExternalRepresentation returns the
// bare key, and the pin is a hash over the full SPKI structure.
let spkiHeaders: [String: [UInt8]] = [
  "EC-256": [0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01,
             0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00],
  "EC-384": [0x30, 0x76, 0x30, 0x10, 0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01,
             0x06, 0x05, 0x2B, 0x81, 0x04, 0x00, 0x22, 0x03, 0x62, 0x00],
  "RSA-2048": [0x30, 0x82, 0x01, 0x22, 0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7,
               0x0D, 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, 0x82, 0x01, 0x0F, 0x00],
]
 
func keyKind(_ key: SecKey) -> String? {
  guard let attributes = SecKeyCopyAttributes(key) as? [CFString: Any],
        let type = attributes[kSecAttrKeyType] as? String,
        let bits = attributes[kSecAttrKeySizeInBits] as? Int else { return nil }
  if type == (kSecAttrKeyTypeECSECPrimeRandom as String) { return "EC-\(bits)" }
  if type == (kSecAttrKeyTypeRSA as String) { return "RSA-\(bits)" }
  return nil
}
 
func leafKey(_ trust: SecTrust) -> SecKey? {
  guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
        let leaf = chain.first else { return nil }
  return SecCertificateCopyKey(leaf)
}
 
func pin(_ bytes: [UInt8]) -> String {
  Data(SHA256.hash(data: bytes)).base64EncodedString()
}
 
// The correct shape: the system's own evaluation first, then the pin.
func spkiPin(of key: SecKey) -> String? {
  guard let kind = keyKind(key), let header = spkiHeaders[kind],
        let raw = SecKeyCopyExternalRepresentation(key, nil) as Data? else { return nil }
  return pin(header + [UInt8](raw))
}
 
enum Policy: String, CaseIterable {
  case plain, ca, leaf, code, noHeader, noEvaluation, blind, looseNoEval, looseBlind
 
  // Each policy gets its own host name, because Info.plist pins by domain.
  var host: String {
    switch self {
    case .plain: "plain.pinlab.localhost"
    case .ca: "ca.pinlab.localhost"
    case .leaf: "leaf.pinlab.localhost"
    case .code, .noHeader, .noEvaluation: "code.pinlab.localhost"
    case .blind: "blind.pinlab.localhost"
    case .looseNoEval, .looseBlind: "loose.pinlab.localhost"
    }
  }
}
 
final class Checker: NSObject, URLSessionDelegate {
  let policy: Policy
  var computed: String?
  init(_ policy: Policy) { self.policy = policy }
 
  func urlSession(
    _ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
    completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
  ) {
    guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
          let trust = challenge.protectionSpace.serverTrust else {
      return completionHandler(.performDefaultHandling, nil)
    }
    let accept = { completionHandler(.useCredential, URLCredential(trust: trust)) }
    let refuse = { completionHandler(.cancelAuthenticationChallenge, nil) }
 
    switch policy {
    case .plain, .ca, .leaf:
      completionHandler(.performDefaultHandling, nil)
 
    case .code:
      guard SecTrustEvaluateWithError(trust, nil), let key = leafKey(trust),
            let pin = spkiPin(of: key) else { return refuse() }
      computed = pin
      codePins.contains(pin) ? accept() : refuse()
 
    case .noHeader:
      // Bug: hashes the bare key, so no pin computed with openssl ever matches.
      guard SecTrustEvaluateWithError(trust, nil), let key = leafKey(trust),
            let raw = SecKeyCopyExternalRepresentation(key, nil) as Data? else { return refuse() }
      let pin = pin([UInt8](raw))
      computed = pin
      codePins.contains(pin) ? accept() : refuse()
 
    case .noEvaluation, .looseNoEval:
      // Bug: compares the pin but never asks whether the chain is valid.
      guard let key = leafKey(trust), let pin = spkiPin(of: key) else { return refuse() }
      computed = pin
      codePins.contains(pin) ? accept() : refuse()
 
    case .blind, .looseBlind:
      // Accepts anything, to see whether Info.plist pins still apply.
      accept()
    }
  }
}
 
func attempt(_ policy: Policy, _ port: Int) async -> String {
  let checker = Checker(policy)
  let configuration = URLSessionConfiguration.ephemeral
  configuration.timeoutIntervalForRequest = 5
  let session = URLSession(configuration: configuration, delegate: checker, delegateQueue: nil)
  defer { session.invalidateAndCancel() }
  do {
    let url = URL(string: "https://\(policy.host):\(port)/")!
    let (_, response) = try await session.data(from: url)
    return (response as? HTTPURLResponse)?.statusCode == 200 ? "ok" : "odd"
  } catch let error as NSError {
    let stream = error.userInfo["_kCFStreamErrorCodeKey"].map { "/\($0)" } ?? ""
    return "\(error.code)\(stream)"
  }
}
 
func say(_ line: String) {
  FileHandle.standardOutput.write(Data((line + "\n").utf8))
}
 
enum Lab {
  static func run() async {
    let policies = Policy.allCases
    say("scenario  " + policies.map { $0.rawValue.padding(toLength: 13, withPad: " ", startingAt: 0) }.joined())
    for (port, name) in scenarios {
      var row = name.padding(toLength: 10, withPad: " ", startingAt: 0)
      for policy in policies {
        row += (await attempt(policy, port)).padding(toLength: 13, withPad: " ", startingAt: 0)
      }
      say(row)
    }
    // What the bare-key bug computes for the legitimate server, beside the real pin.
    let probe = Checker(.noHeader)
    let session = URLSession(configuration: .ephemeral, delegate: probe, delegateQueue: nil)
    _ = try? await session.data(from: URL(string: "https://code.pinlab.localhost:8441/")!)
    say("bare-key hash of K1: \(probe.computed ?? "none")")
    let good = Checker(.code)
    let session2 = URLSession(configuration: .ephemeral, delegate: good, delegateQueue: nil)
    _ = try? await session2.data(from: URL(string: "https://code.pinlab.localhost:8441/")!)
    say("SPKI hash of K1:     \(good.computed ?? "none")")
  }
}
 
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
  func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
  ) -> Bool {
    Task.detached {
      await Lab.run()
      exit(0)
    }
    return true
  }
}

run-ios.sh 用实验里真实的 pin 写出 Info.plist,不借助 Xcode 工程为模拟器编译 App,创建一台一次性的 iPhone 模拟器,把三个根加进这台模拟器的信任库,运行 App,然后再删掉这台模拟器。它不会动你 Mac 本身的信任设置,也不会动你的其他模拟器。

脚本:保存为 run-ios.sh
#!/bin/sh
# Builds PinLab for the iOS Simulator, runs it on a throwaway device that
# trusts the lab's three roots, prints its table and deletes the device.
set -eu
cd "$(dirname "$0")"
CA=$(./pin.sh pki/public-a-ca.pem); K1=$(./pin.sh pki/k1.key); BACKUP=$(./pin.sh pki/backup.key)
identity() { printf '<dict><key>SPKI-SHA256-BASE64</key><string>%s</string></dict>' "$1"; }
 
rm -rf build && mkdir -p build/PinLab.app
cat > build/PinLab.app/Info.plist <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>CFBundleIdentifier</key><string>org.example.pinlab</string>
  <key>CFBundleExecutable</key><string>PinLab</string>
  <key>CFBundleName</key><string>PinLab</string>
  <key>CFBundlePackageType</key><string>APPL</string>
  <key>CFBundleVersion</key><string>1</string>
  <key>CFBundleShortVersionString</key><string>1.0</string>
  <key>CFBundleSupportedPlatforms</key><array><string>iPhoneSimulator</string></array>
  <key>MinimumOSVersion</key><string>18.0</string>
  <key>UIDeviceFamily</key><array><integer>1</integer></array>
  <key>LSRequiresIPhoneOS</key><true/>
  <key>PinLabLeafPins</key><array><string>$K1</string><string>$BACKUP</string></array>
  <key>NSAppTransportSecurity</key><dict>
    <key>NSPinnedDomains</key><dict>
      <key>ca.pinlab.localhost</key><dict>
        <key>NSPinnedCAIdentities</key><array>$(identity "$CA")</array></dict>
      <key>leaf.pinlab.localhost</key><dict>
        <key>NSPinnedLeafIdentities</key><array>$(identity "$K1")$(identity "$BACKUP")</array></dict>
      <key>blind.pinlab.localhost</key><dict>
        <key>NSPinnedCAIdentities</key><array>$(identity "$CA")</array></dict>
    </dict>
    <key>NSExceptionDomains</key><dict>
      <key>loose.pinlab.localhost</key><dict>
        <key>NSExceptionAllowsInsecureHTTPLoads</key><true/></dict>
    </dict>
  </dict>
</dict></plist>
PLIST
plutil -lint build/PinLab.app/Info.plist >/dev/null
 
xcrun --sdk iphonesimulator swiftc -parse-as-library -O \
  -target arm64-apple-ios18.0-simulator app/main.swift -o build/PinLab.app/PinLab
codesign --force --sign - build/PinLab.app 2>/dev/null
 
RUNTIME=$(xcrun simctl list runtimes | awk '/iOS/ {id=$NF} END {print id}')
DEVICE=$(xcrun simctl create PinLab "iPhone 17 Pro" "$RUNTIME")
trap 'xcrun simctl shutdown "$DEVICE" >/dev/null 2>&1; xcrun simctl delete "$DEVICE"' EXIT
xcrun simctl boot "$DEVICE"
for root in public-a-root public-b-root inspect-root; do
  xcrun simctl keychain "$DEVICE" add-root-cert "pki/$root.pem"
done
xcrun simctl install "$DEVICE" build/PinLab.app
xcrun simctl launch --console-pty "$DEVICE" org.example.pinlab 2>/dev/null | tr -d '\r' | grep -E '^(scenario|legit|proxied|renewed|rekeyed|rotated|expired|switched|misissue|sameca|bare-key|SPKI) '
chmod +x run-ios.sh
./serve.sh && ./run-ios.sh; ./serve.sh stop
scenario  plain        ca           leaf         code         noHeader     noEvaluation blind        looseNoEval  looseBlind   
legit     ok           ok           ok           ok           -999         ok           ok           ok           ok           
proxied   ok           -1200/-9802  -1200/-9802  -999         -999         -999         -1200/-9802  -999         ok           
renewed   ok           ok           ok           ok           -999         ok           ok           ok           ok           
rekeyed   ok           ok           -1200/-9802  -999         -999         -999         ok           -999         ok           
rotated   ok           ok           ok           ok           -999         ok           ok           ok           ok           
expired   -1200/-9802  -1200/-9802  -1200/-9802  -999         -999         -1200/-9802  -1200/-9802  ok           ok           
switched  ok           -1200/-9802  ok           ok           -999         ok           -1200/-9802  ok           ok           
misissue  ok           -1200/-9802  -1200/-9802  -999         -999         -999         -1200/-9802  -999         ok           
sameca    ok           ok           -1200/-9802  -999         -999         -999         ok           -999         ok           
bare-key hash of K1: SjXCOVl4c+hrCGRMJOBelLidisx0oa4Giv+4DLWDNAo=
SPKI hash of K1:     U5PGzZgeovRE26eMZw4xCRtJ0s5LrULkQbYioKNo8ZI=

ok 表示请求完成。-1200/-9802 是 ATS 拒绝了连接,-999 是委托取消了认证质询。新模拟器第一次启动大约要一分钟。

六个自测问题

点击问题展开答案。

1. 为什么 pin 是公钥的哈希,而不是证书的哈希?

因为证书每次续签都会变,哪怕密钥没变。对证书做哈希,会在表里例行续签那一行失效;对 SubjectPublicKeyInfo 做哈希,则能扛过去。

2. App 钉的是签发 CA 的密钥。表里哪张它不该接受的证书,它接受了?

被钉 CA 自己签出的冒牌证书。它的链经过被钉的那把密钥,所以 pin 被满足了。只有钉服务器自己的密钥才会拒绝它。

3. 钉叶子扛过了换 CA。为什么?

服务器保留了自己的密钥 K1,只是请另一家 CA 为它签发证书。钉叶子点名的是密钥,不是签发者。

4. 实验里的 App 拒绝了过期证书,为什么跳过 SecTrustEvaluateWithError 仍然危险?

因为做出拒绝的是 ATS,不是代码。像实验里那个宽松主机一样给域名加一条 ATS 例外,同样的代码就会接受过期证书。在没有 ATS 的平台上,它从一开始就会这样。

5. 备用 pin 花掉了什么,又换来了什么?

花掉的是一把提前生成、离线保管的密钥。换来的是一次所有已安装版本都扛得过去的轮换,正如计划内轮换那一行所示。

6. HPKP 的失败,为什么并不说明 App 里也不该做证书固定?

HPKP 让服务器来设 pin,所以入侵者能钉上自己的密钥;App 的 pin 随签名代码发布。锁死的风险仍然存在,因为旧版本保留着旧 pin,备用 pin 和 pin 过期正是为此准备的。

Footnotes

  1. Apple,「Identity Pinning: How to configure server certificates for your app」,2021 年 1 月 14 日。 ↩ ↩2

  2. Android Developers,「Security with network protocols」。 ↩ ↩2

  3. 伪造证书、Fox-IT 调查中认定的 30 万名目标 Gmail 用户,以及 DigiNotar 于 2011 年 9 月 20 日破产,见 Wikipedia: DigiNotar;Google 的说明见 Google 安全博客。 ↩

  4. Apple 支持,「Trust manually installed certificate profiles in iOS, iPadOS, and visionOS」。 ↩

  5. Android Developers,「Network security configuration」。 ↩ ↩2 ↩3

  6. Chrome 的证书透明度执行公告,ct-policy 邮件组;Apple 的政策,Apple 支持。 ↩

  7. Cloudflare,「Certificate authorities」。 ↩

  8. Cloudflare,「Certificate pinning」。 ↩ ↩2

  9. Apple,NSPinnedDomains。 ↩ ↩2

  10. CA/浏览器论坛,SC-081v3 号投票,2025 年 4 月。 ↩

  11. Let’s Encrypt,「Decreasing Certificate Lifetimes to 45 Days」,以及 Profiles。 ↩

  12. Certbot 用户指南。 ↩

  13. Let’s Encrypt,Gen Y 层级,2025 年 11 月 24 日;当前证书链见 Chains of Trust。 ↩

  14. Apple 开发者论坛,731368 号帖,2023 年 6 月。 ↩

  15. Apple 开发者论坛,681734 号帖,2021 年 6 月。 ↩

  16. Apple,「Performing manual server trust authentication」。 ↩

  17. OWASP MASTG,MASTG-TEST-0396。 ↩

  18. TrustKit。 ↩

  19. RFC 7469,「Public Key Pinning Extension for HTTP」,2015 年 4 月。 ↩

  20. Mathias Biilmann Christensen,「Be Afraid Of HTTP Public Key Pinning (HPKP)」,Smashing Magazine,2016 年 10 月 26 日。 ↩

  21. Chrome 67 的弃用与移除;移除记录。 ↩

  22. Firefox 72 开发者说明。 ↩

  23. Chrome Platform Status: Expect-CT。 ↩

  24. SSL Kill Switch 3;objection;Frida 脚本。 ↩

  25. Apple,DCAppAttestService。 ↩

  26. OWASP,MASVS-NETWORK-2;MASWE-0028。 ↩