Rust in Everything
基于 rustls 0.23.46

内网 CA 的站点连不上

主线位置:校验主线的信任锚。路径构建一直往上找,最后要落在 RootCertStore 里的某个锚上;锚是什么、webpki-roots 里有哪 118 个,见服务器证书。报错本身和「漏发中间证书」「自签证书」是同一个 UnknownIssuer,原因的三种分法见 UnknownIssuer。本页只讲一种场景:公网站点一切正常,内网 CA 签的站点连不上。

flowchart TB
  hs["连 git.corp.internal"] --> v{"信任哪些根"}
  v -->|webpki-roots| e1["UnknownIssuer"]
  v -->|再加内部 CA| ok1["Ok"]
  v -->|系统证书库| e2["UnknownIssuer"]
  v -->|SSL_CERT_FILE| ok2["Ok 只剩 1 个根"]
  v -->|平台校验器| e3["UnknownIssuer"]
  v -->|extra_roots| ok3["Ok"]

实验 crate:exp-rustls/pitb-roots。rcgen 现造一个「Corp Internal Root」和它签的 git.corp.internal 证书,内存握手,客户端换几种信任来源。依赖:webpki-roots 1.0.11、rustls-native-certs 0.8.5、rustls-platform-verifier 0.7.1,都从 cargo 缓存解析。ring provider。M2 Max、macOS 27、rustc 1.99.0,release。

现象

== webpki-roots
anchors: 118
webpki-roots only: invalid peer certificate: UnknownIssuer
anchors after add(corp CA): 119
webpki-roots + corp CA: Ok

== rustls-native-certs (SSL_CERT_FILE=None)
certs: 158, errors: 0
add_parsable_certificates: added 158, ignored 0
native roots: invalid peer certificate: UnknownIssuer

== rustls-platform-verifier
Verifier::new: invalid peer certificate: UnknownIssuer
Verifier::new_with_extra_roots(corp CA): Ok

只用 webpki-roots 的客户端,对公网站点没问题,对内部 CA 签的站点报 UnknownIssuer。换成系统证书库(本机 macOS 有 158 张)或平台校验器,结果一样:内部 CA 不在本机的系统信任里。

原因

webpki-roots 是一张编译进二进制的表,内容来自 Mozilla 的根证书计划,只收公开信任的 CA。内部 CA 不会在里面,也不应该在里面。rustls 不会自己去找别的根:ClientConfig::with_root_certificates 收到什么就只信什么,路径构建在锚里找不到 issuer 对得上的名字,就是 UnknownIssuer(路径构建)。

系统证书库也只是另一张表。rustls-native-certs 在 macOS 上读的是系统的信任设置,内部 CA 要先被装进去(通常是公司的设备管理去做),这台机器上没有。

修法

一:在 webpki-roots 之外加上内部 CA。 RootCertStore 实现了 Extend<TrustAnchor>,webpki-roots 的 TLS_SERVER_ROOTS 本身就是锚,直接接进去;内部 CA 的证书用 add:

let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
roots.add(corp_ca_der)?; // CertificateDer, e.g. from CertificateDer::from_pem_file

上面输出里锚从 118 变成 119,两边的站点都能连。内部 CA 的 PEM 怎么读、读不出来时报什么,见 PEM 的读法。add 对一张证书解析失败会返回错误;系统证书库那种大集合用 add_parsable_certificates,它跳过解析不了的,返回 (成功数, 失败数),失败数要看一眼。

二:系统证书库加 SSL_CERT_FILE,但要知道它是「替换」不是「追加」。 rustls-native-certs 0.8.5 的 load_native_certs 文档写着:SSL_CERT_FILE 或 SSL_CERT_DIR 任意一个设了,「certificates are only loaded from the locations specified via environment variables and not the platform-native certificate store」。同一个程序,设 SSL_CERT_FILE=corp-ca.pem:

== rustls-native-certs (SSL_CERT_FILE=Some("corp-ca.pem"))
certs: 1, errors: 0
add_parsable_certificates: added 1, ignored 0
native roots: Ok

内网站点通了,代价是 158 个系统根全没了,这个进程从此连不上任何公网站点。要两者都有,SSL_CERT_FILE 指向的文件里得是「系统根加内部 CA」的合集,或者干脆不用环境变量,在代码里按修法一合并。

三:平台校验器。 rustls-platform-verifier 不导出根证书,而是把整条链交给操作系统去验(macOS 是 Security.framework;Linux 上没有这样的系统接口,它的 README 说那里用的是「fall-back webpki verifier」),吊销检查、企业下发的信任设置由系统负责;内部 CA 由设备管理装进系统信任之后它就认。在系统里装不了时,Verifier::new_with_extra_roots 可以另带几个根,上面输出的最后一行就是它。它实现的是 ServerCertVerifier,要经 dangerous().with_custom_certificate_verifier(...) 装进 ClientConfig,或者用它自带的 BuilderVerifierExt::with_platform_verifier。

本站的 sqlx 和 lettre 用的是编译进去的 webpki-roots,连内部 CA 签的数据库或邮件服务器时会撞上这一页,见本站的 rustls。

不要用「跳过校验」来绕:那会连 CertificateVerify 签名的作用一起丢掉,见危险的校验器。只信一张内网服务器证书也有更窄的写法(固定证书),同样在那一页。

怎么检查

  • 对方的链终止在哪个根: openssl s_client -connect host:443 -showcerts,最后一张的 i: 就是要信任的根。名字看着像公司内部的(Corp、Internal、公司名),webpki-roots 里不会有。
  • 客户端实际有几个锚: 构建完打印 roots.len()。118 左右是只有 webpki-roots;1 或个位数,多半是 SSL_CERT_FILE / SSL_CERT_DIR 被设成了一个小文件。
  • 环境变量从哪来: 容器镜像、CI 和公司的 shell 配置常常预设 SSL_CERT_FILE。程序里用 rustls-native-certs 时,启动日志里打印一下这两个变量的值。

小结

  • webpki-roots 只有公开信任的根,内部 CA 签的站点报 UnknownIssuer,系统证书库和平台校验器在没装内部 CA 的机器上也一样。
  • 最直接的修法:RootCertStore 先 extend webpki-roots,再 add 内部 CA。
  • rustls-native-certs 的 SSL_CERT_FILE / SSL_CERT_DIR 是替换整个系统库,实测 158 个根变成 1 个。
  • 平台校验器把校验交给操作系统,new_with_extra_roots 可以额外带根。

💬 关联讨论

发起讨论
加载中...