OpenSSL、mkcert 与 keytool 证书链验证工具手册
浏览器能开,Java 却在握手处倒下
一个很典型的现场是:开发者打开 https://dev.local.test:8443 一切正常,Spring Boot 调同一地址却抛出 PKIX path building failed;同事用 IP 访问又得到 hostname mismatch。三种现象看似矛盾,其实分别落在三个判断上:服务端送出了什么证书链,客户端信任哪个根,URL 主机名是否出现在证书的 SAN 中。
先不要导入证书,也不要加 -k。把连接事实保存下来:
openssl version -a
openssl s_client -connect dev.local.test:8443 \
-servername dev.local.test -showcerts </dev/null-connect 决定 TCP 目的地,-servername 发送 SNI,虚拟主机据此选择证书;-showcerts 只打印服务端发送的列表,不表示链已经可信。真正能作为失败证据的命令还要指定信任锚、主机名并让验证错误终止握手:
openssl s_client -connect dev.local.test:8443 \
-servername dev.local.test \
-CAfile certs/dev-root.pem \
-verify_return_error \
-verify_hostname dev.local.test </dev/null成功输出应包含 Verification: OK 或 Verify return code: 0 (ok)。unable to get local issuer certificate 指向缺失的中间证书或信任锚,hostname mismatch 指向 SAN,握手前的 Connection refused 则与证书无关。OpenSSL 的 s_client、x509 和 verify 手册分别对应在线握手、证书对象和离线链验证。
从一张本地证书跑起 HTTPS
OpenSSL 通常随系统开发工具或包管理器安装;Windows 环境需要明确选择发行包,不能假定系统自带。OpenSSL 4.0.x 是当前功能发行线,3.5.x 是长期支持线,发行版和语言运行时还可能携带自己的补丁版本;命令行为必须以实际 openssl version -a 和供应商支持线为准。mkcert 当前稳定版是 1.4.4,可通过 Homebrew、Chocolatey、Scoop、Linux 发行版或项目提供的预编译二进制安装;Linux 和 Firefox 信任还可能需要 NSS 的 certutil。Java 的 keytool 随 JDK 提供,不是独立下载的证书工具。安装后先确认三个二进制的真实路径:
openssl version -a
mkcert -version
mkcert -CAROOT
"$JAVA_HOME/bin/java" -XshowSettings:properties -version 2>&1 | grep 'java.home'
"$JAVA_HOME/bin/keytool" -helpmkcert -install 会创建本地 CA,并尝试安装到系统、NSS 或 Java 信任库。TRUST_STORES=system,nss,java 可以限制要处理的信任库,JAVA_HOME 会影响它找到哪个 Java store;这些字段改变的是“谁信任本地 CA”,并不会替服务端启用 HTTPS。CA 目录中的 rootCA.pem 是可分发的公钥证书,rootCA-key.pem 则能签发任意受本机信任的证书,绝不能进入仓库、镜像、群聊或 CI 制品。
mkcert -install
mkdir -p certs
mkcert -cert-file certs/dev.local.test.pem \
-key-file certs/dev.local.test-key.pem \
localhost 127.0.0.1 ::1 dev.local.test
cp "$(mkcert -CAROOT)/rootCA.pem" certs/dev-root.pem名称参数决定 SAN:localhost 和 dev.local.test 进入 DNS SAN,127.0.0.1 与 ::1 进入 IP SAN。-cert-file、-key-file 只改变输出位置,且必须放在名称列表之前。把 dev.local.test 指到回环地址后,可用 OpenSSL 自带服务器完成不依赖框架的正向实验:
openssl s_server -accept 8443 -www \
-cert certs/dev.local.test.pem \
-key certs/dev.local.test-key.pem另一个终端执行严格验证和 HTTP 请求:
openssl s_client -connect 127.0.0.1:8443 \
-servername dev.local.test -CAfile certs/dev-root.pem \
-verify_return_error -verify_hostname dev.local.test </dev/null
curl --cacert certs/dev-root.pem https://dev.local.test:8443/前者证明链、SNI 和 SAN,后者证明 TLS 之上的 HTTP 可用。mkcert 的项目说明还列出了各系统安装入口、受支持的 root store 和 CAROOT 行为。
故意破坏 SAN,读懂第一份反证
保持服务端不变,只把期望主机名换成未签入证书的名称:
openssl s_client -connect 127.0.0.1:8443 \
-servername dev.local.test -CAfile certs/dev-root.pem \
-verify_return_error -verify_hostname api.local.test </dev/null预期得到 hostname mismatch。这个实验刻意让“链可信”保持不变,仅破坏身份匹配,所以修复动作只能是重签包含 api.local.test 的证书,而不是再导入一次根 CA。再去掉 -CAfile,在一个没有安装该根的隔离容器中执行同一命令,预期转为 issuer 或 self-signed chain 错误;这才是信任锚问题。
还可以离线检查叶子证书,让故障证据不依赖服务是否在线:
openssl x509 -in certs/dev.local.test.pem -noout \
-subject -issuer -serial -fingerprint -sha256 \
-ext subjectAltName -dates
openssl verify -CAfile certs/dev-root.pem certs/dev.local.test.pem真实服务通常不是叶子直接由根签发。把中间证书单独作为“不受信的构链材料”,根证书只作为信任锚,才能证明完整链路:
openssl verify -show_chain -purpose sslserver \
-CAfile certs/root-ca.pem \
-no-CApath -no-CAstore \
-untrusted certs/intermediate-ca.pem \
-verify_hostname service.example.test \
certs/server.pem删除 -untrusted 后应稳定得到 issuer 相关错误;把中间 CA 错放进 -CAfile 并不能代表客户端已经采用组织期望的根信任策略。线上 s_client -showcerts 只展示服务端发送了什么,不会替服务补齐缺失中间证书,也不会证明这些证书形成了可信链。
证书链是按签发者签名连接的有向路径,验证器从叶子向上寻找中间 CA,直到本地信任锚;服务端通常发送叶子和中间证书,根证书由客户端预置。SAN 是服务身份,信任库是本地策略,私钥则证明服务持有证书对应密钥。上线前还要证明叶子证书与私钥确实是一对,而不是只看文件名:
openssl x509 -in certs/server.pem -pubkey -noout \
| openssl pkey -pubin -outform DER \
| openssl dgst -sha256
openssl pkey -in certs/server-key.pem -pubout -outform DER \
| openssl dgst -sha256
openssl pkey -in certs/server-key.pem -check -noout前两条摘要必须相同,第三条检查私钥内部一致性。私钥命令默认应交互读取口令或使用受控口令源;不要把口令直接写进 -passin pass:...。把三者混成“证书文件”会导致最危险的误操作:把私钥当 CA 分发,或把任意叶子证书导成永久信任锚。
keytool 管的是哪一个 Java 世界
keytool -cacerts 操作当前 JDK 的 cacerts,典型位置是 $JAVA_HOME/lib/security/cacerts。IDE 自带 JDK、Gradle daemon、容器和 CI 可能各用一份。先打印进程的 java.home,再用同一目录下的 keytool,比相信 PATH 更可靠。
导入前先从独立渠道核对 SHA-256 指纹;别用 -noprompt 跳过未知 CA 的身份确认。为了让实验可回滚,更推荐复制一份项目专用 truststore:
mkdir -p .local
cp "$JAVA_HOME/lib/security/cacerts" .local/dev-cacerts
"$JAVA_HOME/bin/keytool" -printcert -file certs/dev-root.pem
"$JAVA_HOME/bin/keytool" -importcert -trustcacerts \
-keystore .local/dev-cacerts \
-alias blog-stack-dev-root \
-file certs/dev-root.pem
"$JAVA_HOME/bin/keytool" -list -v \
-keystore .local/dev-cacerts -alias blog-stack-dev-root-keystore 选择状态文件,-alias 是条目的稳定身份,重复 alias 会阻止误覆盖;-trustcacerts 允许构链时参考 JDK CA 集合,但不会替你确认来源。应用通过 -Djavax.net.ssl.trustStore=.local/dev-cacerts 和受控启动入口提供的 javax.net.ssl.trustStorePassword 使用它。系统属性如果直接拼在共享 shell 命令中仍可能被进程观察,CI 和服务管理器应从权限受控的 secret source 生成启动配置。Oracle 的 keytool 手册明确要求在导入根 CA 前检查指纹。
PKCS12、JKS 与私钥条目不是同一种东西
JDK 9 及以后默认 keystore type 是 PKCS12;JKS 是 Java 专有格式,不能因为文件扩展名是 .jks 就推断实际 store type。先让 keytool 读取并确认条目类型:trustedCertEntry 只有证书,适合 truststore;PrivateKeyEntry 同时持有私钥与证书链,用作服务端 keystore 或客户端 mTLS 身份。
将 PEM 私钥、叶子证书和中间链封装为 PKCS12 时,让 OpenSSL 交互询问输出口令,并检查包内对象,不把口令写进 argv:
openssl pkcs12 -export \
-inkey certs/server-key.pem \
-in certs/server.pem \
-certfile certs/intermediate-ca.pem \
-name server-tls \
-out .local/server.p12
openssl pkcs12 -in .local/server.p12 -info -noout
"$JAVA_HOME/bin/keytool" -list -v \
-storetype PKCS12 -keystore .local/server.p12 -alias server-tls-certfile 添加的是随身份携带的额外证书,不能替代客户端 truststore 中的根。若旧应用只接受 JKS,用 -importkeystore 做显式转换,并在转换前后核对 alias、PrivateKeyEntry 和证书链长度:
"$JAVA_HOME/bin/keytool" -importkeystore \
-srckeystore .local/server.p12 -srcstoretype PKCS12 \
-destkeystore .local/server.jks -deststoretype JKS \
-srcalias server-tls -destalias server-tls
"$JAVA_HOME/bin/keytool" -list -v \
-keystore .local/server.jks -storetype JKS -alias server-tls转换成功只证明 keystore 文件可读,不证明运行时接受其中算法,也不证明服务发送了完整链。启动服务后仍需用 s_client -showcerts -verify_return_error -verify_hostname 从外部做正向验证,再用错误主机名或缺失根做反证。旧 JKS 的迁移回滚应保留原文件只读副本和摘要,切换失败时恢复原路径与启动参数,而不是把新文件再次反向覆盖旧文件。
反向实验是让应用改回默认 truststore。若默认库没有开发 CA,预期恢复 PKIX path building failed;如果仍成功,说明 CA 已进入系统 JDK、IDE JDK或应用另有 javax.net.ssl.trustStore。需要更细证据时临时启用 -Djavax.net.debug=ssl,handshake,trustmanager,日志会显示加载的 truststore、候选 issuer 和拒绝位置。调试日志可能包含主机名、证书主体与会话信息,只应短时保存并按敏感诊断数据清理。
接入 Node、容器和项目脚本
信任不会自动跨越进程与镜像边界。Node 的当前运行模式可能使用 bundled CA、显式启用的 system CA 或额外 CA;在需要追加特定 PEM bundle 时使用 NODE_EXTRA_CA_CERTS,它只在 Node 进程启动时读取,且应用显式传入 ca 时不会继续采用默认和额外 CA。支持的 Node 发行线也可用 --use-system-ca 或 NODE_USE_SYSTEM_CA=1 叠加系统信任,但不能把该能力反推到旧运行时。任何版本都不能用 NODE_TLS_REJECT_UNAUTHORIZED=0 关闭验证。Docker 拉取私有 registry 时是 Docker host/daemon 在验证,而容器里的应用请求 HTTPS 时读取镜像自己的 CA store,两者要分别处理。可对照 Node 企业网络配置与 Docker CA 证书说明。
项目应提交生成与验证方法,不提交个人 CA 或任何私钥:
certs/*.pem
certs/*.key
certs/*.p12
certs/*.jks
.local/dev-cacertsCI 中可以从受控 secret/file store 注入测试 CA,并在 job 结束时销毁。门禁脚本至少要检查 SAN、链、到期窗口和私钥泄漏:
openssl x509 -in "$CERT_FILE" -noout -ext subjectAltName
openssl verify -CAfile "$CA_FILE" "$CERT_FILE"
openssl x509 -in "$CERT_FILE" -checkend "$RENEW_WINDOW_SECONDS" -noout
git grep -n -- 'BEGIN .*PRIVATE KEY' -- ':!*.example' && exit 1 || trueRENEW_WINDOW_SECONDS 应来自证书生命周期和发布提前量,而不是全项目共用一个拍脑袋数字。容量上,TLS 失败常放大为连接重试、线程等待和外部调用雪崩;监控应把握手失败按 issuer、hostname、运行时和目标拆分,并观察重试量是否与证书错误同步上升。
清理一次信任变更
先停掉使用证书的服务,再删除项目专用 truststore、PKCS12/JKS 身份库、叶子证书和私钥;删除前记录文件摘要和 alias 清单,确认服务启动参数已经切回原路径。如果曾改全局 Java 库,用同一个 JDK 删除 alias 并确认查无此项:
"$JAVA_HOME/bin/keytool" -delete -cacerts -alias blog-stack-dev-root
"$JAVA_HOME/bin/keytool" -list -cacerts -alias blog-stack-dev-root第二条应失败并报告 alias 不存在。mkcert 的 mkcert -uninstall 负责移除其安装的本地 CA 信任,但 CA 文件仍需按 mkcert -CAROOT 定位和人工处置;执行前保存需要的公开证书指纹,执行后重启受影响浏览器和长驻 Java/Node 进程。移除 NODE_EXTRA_CA_CERTS、JVM 启动参数、容器挂载和镜像层后,再次运行严格握手,预期从成功变为明确的不受信错误,这才证明回滚真的生效。
如果 rootCA-key.pem 曾离开受控终端,不能把“删文件”当作恢复。应视为 CA 私钥泄漏:撤销所有对该根的信任,重建开发 CA,重新签发叶子证书并扫描仓库历史、制品、缓存和终端基线。
从个人开发走向长期治理
mkcert 适合每位开发者拥有独立本地 CA;共享测试环境更适合由受控内部 CA 或短生命周期签发服务提供证书;公网生产入口应进入组织的证书签发、续期、吊销和审计体系。OpenSSL 是观察和验证工具,keytool 是 Java store 管理工具,它们都不是 CA 治理平台。
架构评审时,先画出浏览器、OS、Node、JDK、Docker daemon、容器镜像和企业 TLS 代理各自的信任库。每个新增 CA 都要有 owner、来源与指纹、允许的环境、分发通道、到期或撤销条件、删除命令和验证证据。企业代理看到的 issuer 可能不是公网真实链,因此公网故障还要由非拦截网络或受控外部探针交叉验证。
成本不只是一张证书。全局修改开发机降低隔离性,给每个镜像烘焙 CA 增加更新成本,运行时挂载降低镜像漂移却增加部署依赖,独立 truststore 隔离最好但要求所有启动入口统一参数。选择哪条路径,应由信任半径、轮换频率、运行时数量和恢复演练共同决定;任何“临时关闭 TLS 校验”都不应进入共享脚本、CI 或生产配置。
