内网 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先extendwebpki-roots,再add内部 CA。 - rustls-native-certs 的
SSL_CERT_FILE/SSL_CERT_DIR是替换整个系统库,实测 158 个根变成 1 个。 - 平台校验器把校验交给操作系统,
new_with_extra_roots可以额外带根。