内网 HTTPS 自签证书:CA 证书链、SAN 配置与部署排错
简介:ZXCA自信数字证书制作工具面向个人与小型组织,提供数字证书创建、签发、管理及文件加密、代码签名等实操环境,适合需要理解PKI流程、提升数据安全与隐私保护能力的初学者和运维人员。资源包共8个文件,由4个txt说明、2个exe程序、1个chm帮助文档和1个html页面组成,压缩后约4.84MB,体积轻便,便于在本地或隔离环境中快速部署查阅。已有628人学习下载,表明其在证书制作与安全应用方面具有一定参考价值。通过ZXCA专业版可实践公私钥生成、证书申请与撤销、PEM/DER格式导入导出等环节;文件小保镖组件则围绕加密存储、安全分享、权限控制和备份恢复展开,帮助读者把证书原理落到文件保护与代码签名场景中,形成从证书管理到安全应用的完整认知。
1. 自签证书不是点一下就能生成:ZXCA 这类工具替你承担的三层工作
内网起了个 HTTPS 服务,浏览器地址栏挂个大红叉,接口调用直接抛unable to get local issuer certificate,这种场面做过内网系统的基本都遇过。ZXCA 这类自签数字证书制作工具要解决的就是这一步:不依赖任何公共 CA,从零搭一条「根 CA → 中间 CA → 站点证书」的链,把内网域名、内网 IP、localhost 一次性签进 SAN 里,再导出成 PEM、PKCS#12、JKS 这些服务端和客户端都认识的格式。名字里那个「自信」其实是「自签」的口误,但它恰好点中了要害——自己给自己背书。适合的人群很具体:做内网服务的后端、本地开发要跑 HTTPS 的前端、设备出厂要预置证书的嵌入式团队,以及需要双向认证但不想走外部签发流程的项目组。下面按「链怎么搭、参数怎么填、服务端怎么挂、哪里会翻车」的顺序走一遍。
2. 整条证书链的三层结构:根 CA、中间 CA 与站点证书靠什么串成一串
很多人第一次用工具生成证书,看到输出目录里躺着三对文件会发懵:为什么不能只生成一张?原因是 X.509 的信任模型不是「一张证书说自己是自己」,而是「一张证书由上一张签,一直签到你信任库里的那一个点」。工具做的所有事情,本质都是把这条链的每一环按规则拼好,再把私钥和证书配对导出。理解了这条链,工具界面上那些看着像玄学的开关才有意义。
2.1 三张证书的签名关系与信任传递路径
根 CA 是自签的,也就是 issuer 和 subject 完全一样,它由自己的私钥签自己,所以它天然无法被验证,只能被「无条件信任」——这就是为什么根证书必须手工装进信任库。中间 CA 由根 CA 签,站点证书由中间 CA 签。客户端握手时拿到的是站点证书,它会顺着 issuer 字段往上找,直到在本地信任库里命中某张根证书,链闭合,校验通过。
链路里还有两个容易被忽略的字段:Subject Key Identifier(SKI)和 Authority Key Identifier(AKI)。SKI 是这张证书公钥的哈希摘要,AKI 是签发者证书的 SKI。客户端在同名 CA 有多张证书时,靠这两个字段精确匹配到底该用哪一张;缺了它们,链在证书轮换之后就可能接错,表现是「明明装了新根证书,还是报校验失败」。我一般会在所有签发请求的扩展文件里显式写上subjectKeyIdentifier=hash和authorityKeyIdentifier=keyid,issuer,不依赖工具的默认行为。
为什么非要插一层中间 CA,而不是根 CA 直接签站点证书?因为根私钥的使用频率越低越安全。根私钥签完中间 CA 就该离线封存,日常签发全交给中间 CA;中间 CA 泄露了,用根吊销重签一张就行,信任库里的根不用动。这个分层在公有 CA 体系里是硬性要求,在自签场景里属于「多花十分钟、省掉以后一次全量换证」的性价比操作。
| 角色 | 由谁签发 | 建议有效期 | 私钥存放 | 关键扩展 |
|---|---|---|---|---|
| 根 CA | 自己签自己 | 10 年 | 离线,加密保存 | CA:TRUE, pathlen:1 |
| 中间 CA | 根 CA | 3~5 年 | 签发机,权限 600 | CA:TRUE, pathlen:0 |
| 站点证书 | 中间 CA | ≤397 天 | 服务端本机 | CA:FALSE+ SAN |
pathlen这个参数值得单独说一句。它限制的是「这张 CA 证书下面还能再挂几层 CA」。根设pathlen:1表示下面只允许一层中间 CA,中间设pathlen:0表示它只能签终端证书、不能再签 CA。设错了不会立刻报错,等到有人拿中间 CA 又签了一层 CA,客户端才在链校验时报path length constraint exceeded,那时候排查成本就高了。
2.2 手工走一遍 OpenSSL,才看得懂工具在替你做什么
工具把参数收进表单,但排错时你还是得回到命令行。下面这套命令是自签场景里最常见的手工路径,跑通一遍,工具界面上每个输入框对应什么就清楚了。
先生成根 CA。私钥用 AES-256 加密,密码单独记;根私钥一旦泄露,整条链等同于作废:
# 根 CA 私钥,4096 位,带口令保护 openssl genrsa -aes256 -out rootCA.key 4096 # 自签根证书,有效期 3650 天,关键扩展必须 critical openssl req -x509 -new -key rootCA.key -sha256 -days 3650 \ -out rootCA.crt \ -subj "/C=CN/O=Demo Lab/OU=Infra/CN=Demo Lab Root CA R1" \ -addext "basicConstraints=critical,CA:TRUE,pathlen:1" \ -addext "keyUsage=critical,keyCertSign,cRLSign"-addext需要 OpenSSL 1.1.1 及以上,老版本只能靠配置文件里的[v3_ca]段。keyUsage里的keyCertSign是「允许签发证书」,cRLSign是「允许签吊销列表」,这两个是 CA 证书的必备项,漏了就会出现「能建链但验不过」的怪现象。
接着签中间 CA。注意这一步是「先生成 CSR,再由根 CA 签」,不是自签:
# 中间 CA 私钥,2048 位足够,日常签发都靠它 openssl genrsa -out issuingCA.key 2048 openssl req -new -key issuingCA.key -out issuingCA.csr \ -subj "/C=CN/O=Demo Lab/OU=Infra/CN=Demo Lab Issuing CA R1" # 中间 CA 的扩展文件 cat > issuingCA.ext <<'EOF' basicConstraints=critical,CA:TRUE,pathlen:0 keyUsage=critical,keyCertSign,cRLSign subjectKeyIdentifier=hash authorityKeyIdentifier=keyid:always EOF openssl x509 -req -in issuingCA.csr \ -CA rootCA.crt -CAkey rootCA.key -CAcreateserial \ -days 1825 -sha256 -extfile issuingCA.ext \ -out issuingCA.crt-CAcreateserial会生成一个.srl文件维护序列号计数器。单机顺序签发没问题,但如果有多个进程并发签发同一张 CA,这个计数器会产生竞争,出现重复序列号。常见做法是签发前加文件锁,或者干脆由一台签发节点统一出口。
最后签站点证书,这一步的核心全在扩展文件里:
openssl genrsa -out app.demo.internal.key 2048 openssl req -new -key app.demo.internal.key -out app.demo.internal.csr \ -subj "/C=CN/O=Demo Lab/OU=Infra/CN=app.demo.internal" cat > app.ext <<'EOF' basicConstraints=critical,CA:FALSE keyUsage=critical,digitalSignature,keyEncipherment extendedKeyUsage=serverAuth,clientAuth subjectAltName=DNS:app.demo.internal,DNS:*.demo.internal,DNS:localhost,IP:10.20.30.40,IP:127.0.0.1 subjectKeyIdentifier=hash authorityKeyIdentifier=keyid,issuer EOF openssl x509 -req -in app.demo.internal.csr \ -CA issuingCA.crt -CAkey issuingCA.key -CAcreateserial \ -days 397 -sha256 -extfile app.ext \ -out app.demo.internal.crt-days 397不是随手写的,subjectAltName里的 IP 也不能写成 DNS 类型,这两点后面会各用一节展开。跑完这三步,你手上应该有六份文件:两张 CA 的证书和私钥、一张站点证书和私钥,外加两个.srl。工具生成出来的目录结构基本就是这个骨架,只是文件名更好看一点。
2.3 密钥算法的取舍:RSA 2048 还是 ECDSA P-256
工具的表单里通常会有算法下拉框,默认多半是 RSA 2048。这个默认值不算错,但也不是唯一答案。
RSA 的优势是兼容性天花板最高,十几年前的设备、老版本的 Java 客户端、各种嵌入式 TLS 栈都吃这一套。代价是握手开销大、证书体积大、握手时多一次密钥交换的 CPU 消耗。ECDSA P-256 的优势是证书小、握手快,同等安全强度下密钥长度短得多;劣势是老旧设备可能压根不支持,尤其是那些 TLS 栈停在 1.0/1.1 时代的工控设备。
我的判断标准很简单:链路两端只要有一端是不可控的黑盒设备,就用 RSA 2048;纯内网的现代服务之间互调,用 P-256 没问题。生成命令换成:
# ECDSA P-256 私钥 openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 \ -out app.demo.internal.key换成 ECDSA 之后,扩展文件里有个细节要改:keyUsage里的keyEncipherment对 ECDSA 证书没有意义(ECDSA 不做密钥加密,只做签名),应该只留digitalSignature。在 TLS 1.3 里keyEncipherment本来也不参与密钥交换,但一些中间件在做策略校验时会因为「ECDSA 证书带了 keyEncipherment」报配置异常,属于典型的自己给自己挖坑。
| 维度 | RSA 2048 | RSA 4096 | ECDSA P-256 |
|---|---|---|---|
| 老设备兼容 | 最好 | 好 | 一般 |
| 证书体积 | 约 1.2 KB | 约 2 KB | 约 0.7 KB |
| 握手 CPU 开销 | 中 | 高 | 低 |
| 适合场景 | 通用内网 | 根 CA | 现代服务间 |
| 私钥文件长度 | 1.7 KB | 3.2 KB | 0.2 KB |
根 CA 用 4096 位 RSA 是很常见的做法,因为根私钥一年也用不了几次,性能无所谓,安全边际拉满;中间 CA 和站点证书用 2048 位 RSA 或 P-256 就够。
2.4 有效期、序列号与 Basic Constraints:工具默认值里最容易埋雷的三个字段
这三个字段的共同点是:设错了当场不报错,等到某天某个客户端升级了才开始拦,属于典型的「今天不疼、明天要命」。
有效期这块,根 CA 给 10 年、中间 CA 给 3~5 年、站点证书给 397 天,是我目前比较稳的一套配比。站点证书压在 397 天以内,是因为移动端系统对 TLS 服务器证书的有效期上限卡在 398 天,这个限制对私有 CA 签出来的证书同样生效。很多人第一次踩这个坑,都是因为工具表单里填了「3650 天」图省事,结果桌面端能用、手机端直接拒绝。另外openssl x509 -req不带-days时默认只有 30 天,这个默认值坑过的人不在少数。
序列号的问题更隐蔽。X.509 要求同一张 CA 签出的证书序列号唯一,客户端和中间缓存会拿序列号做去重和吊销索引。并发签发导致撞号之后,可能出现「两张不同域名的证书被同一个缓存槽位覆盖」的现象,表现是访问 A 站点偶尔返回 B 站点的证书。这类问题排查起来极其费劲,因为复现概率低。规避方式就是前面说的加锁,或者把签发收敛到单点。
Basic Constraints 必须标critical。这个标记的意思是「客户端不认识这个扩展就必须拒绝这张证书」,听起来很严格,但恰恰是它保证了老客户端不会把一张 CA 证书误当成站点证书用。曾经有工具为了让证书「到处都能用」,把站点证书的basicConstraints设成了CA:TRUE,结果部分客户端直接把这张证书当成了可签发证书,等于给自己开了一个伪造入口。
3. 填对字段再谈生成:DN、SAN、有效期与四种导出格式
工具跑通只是第一步,真正决定这套证书能不能用的,是表单里那几个字段怎么填。这一章按「填什么 → 为什么这么填 → 导成什么格式 → 装到哪里」的顺序捋一遍,基本覆盖日常百分之九十的需求。
3.1 DN 里哪些字段真的会被校验
DN(Distinguished Name)由 C、ST、L、O、OU、CN 这些字段组成。在现代 TLS 校验里,真正影响连通性的只有 CN 和 SAN,而且 CN 的地位已经降到「仅作展示」。主流浏览器从很早就开始忽略 CN、只看 SAN,如果证书里没有 SAN 扩展,哪怕 CN 写得完全正确,也会直接报ERR_CERT_COMMON_NAME_INVALID。
不过「只作展示」不等于可以乱填。有两类客户端仍然会读 CN:一类是某些 Java 客户端的 hostname verifier 在关闭 SNI 场景下的降级逻辑,另一类是部分老版本命令行工具。我的习惯是让 CN 和 SAN 里的主域名保持一致,这样无论对端读哪个字段,结果都一样,省掉一轮排查。
还有一个坑是 DN 里的中文。X.509 的字符串编码有多种,工具默认可能给 UTF8String,而个别老设备只认 PrintableString,解析直接失败。自签场景里 DN 本来就是给人看的,用纯英文和数字最省事,比如O=Demo Lab、OU=Infra,别写中文部门名。
| 字段 | 是否影响校验 | 填写建议 |
|---|---|---|
| CN | 老客户端可能读 | 与主 SAN 域名一致 |
| O / OU | 不影响 | 用于区分环境,纯英文 |
| C / ST / L | 不影响 | 可省略,非要去的话用标准两字母码 |
| SAN | 决定成败 | 必须覆盖所有访问方式 |
3.2 SAN 是唯一的判断依据:DNS、IP、通配符与端口怎么写
SAN(Subject Alternative Name)是一个可以塞多条记录的列表,每条记录带类型前缀。写错类型是最常见的失败原因:DNS:10.20.30.40是合法的语法,但客户端在按 IP 访问时只会去匹配IP:类型的条目,于是校验失败,报错信息还特别含糊。
几条硬规则:
- DNS 名一律用
DNS:前缀,多个域名用逗号分隔,不要另起一行。 - IP 一律用
IP:前缀,IPv4 和 IPv6 分开写。127.0.0.1和::1是两个不同的条目,只写一个的话另一条路走不通。 - 通配符
*.demo.internal只覆盖一级子域,a.demo.internal能匹配,a.b.demo.internal不能,裸域demo.internal也不能。要全覆盖就三条都写。 - 端口不参与证书校验。TLS 的 SNI 只带主机名,所以
:8443、:9443这些端口用的是同一张证书,不需要额外加条目。 - 用 IDN 域名的话,证书里要放 punycode 形式,不要放 Unicode 原文。
一份覆盖面比较全的扩展文件大概长这样:
cat > app.ext <<'EOF' basicConstraints=critical,CA:FALSE keyUsage=critical,digitalSignature,keyEncipherment extendedKeyUsage=serverAuth,clientAuth subjectAltName=@alt_names subjectKeyIdentifier=hash authorityKeyIdentifier=keyid,issuer [alt_names] DNS.1 = app.demo.internal DNS.2 = *.demo.internal DNS.3 = demo.internal DNS.4 = localhost IP.1 = 10.20.30.40 IP.2 = 127.0.0.1 IP.3 = ::1 EOF用@alt_names段落引用的写法比把一长串塞在一行里好维护得多,工具如果支持导入配置文件,直接复用这份就行。改完之后记得重新签,别改配置文件不重签,然后对着旧证书排查半天。
3.3 导出格式:PEM、DER、PKCS#12、JKS 各自交给谁用
同一个证书在不同地方叫不同名字、装不同格式,这是新手最容易混乱的一块。判断依据只有两个:这份文件里有没有私钥,以及接收方要求的容器格式是什么。
| 格式 | 典型后缀 | 含私钥 | 密码保护 | 谁在用 |
|---|---|---|---|---|
| PEM | .crt.pem.key | 分开存 | 私钥可加密 | Nginx、Node、curl、Python |
| DER | .der.cer | 不含 | 无 | 部分设备导入、Windows 双击安装 |
| PKCS#12 | .p12.pfx | 含 | 必须设 | Java、Windows 证书存储、浏览器 |
| JKS | .jks | 含 | 必须设 | 老版本 Java 生态 |
PEM 是文本格式,-----BEGIN CERTIFICATE-----开头的那种,最通用。PKCS#12 是二进制容器,把证书链和私钥打包成一个文件,必须带密码。JKS 是 Java 私有格式,JDK 9 之后默认已经改成 PKCS12,新项目不必再用 JKS。
从 PEM 转 PKCS#12 的命令:
# -certfile 带上中间 CA,这样 p12 里是一条完整的链 openssl pkcs12 -export \ -in app.demo.internal.crt \ -inkey app.demo.internal.key \ -certfile issuingCA.crt \ -name app-demo \ -out app-demo.p12 \ -passout pass:changeit-name app-demo是给这个条目起的别名,Java 侧按别名取证书;不给别名的话某些工具链会取到一个空字符串别名,导入时找不到。-certfile这个参数很多人会漏,导致导出的 p12 里只有叶子证书、没有中间 CA,装到 Java 里之后就是经典的PKIX path building failed。
如果确实需要转 JKS:
keytool -importkeystore \ -srckeystore app-demo.p12 -srcstoretype PKCS12 -srcstorepass changeit \ -destkeystore app-demo.jks -deststoretype JKS -deststorepass changeit转换过程会提示「JKS 是专有格式,建议用 PKCS12」,按提示确认即可。这条命令只在对接老系统时值得跑,新项目直接用 p12 更省事。
3.4 把根证书装进信任库:五个位置的命令
自签证书能不能被信任,全看这一步。原则只有一条:只装根证书,不要装中间 CA 和站点证书。装中间 CA 看着也能通,但等到中间 CA 轮换,机器上会留下一堆互相冲突的信任条目,属于典型的「今天省事、以后还债」。
下面是几个常见位置的落地命令,注意把文件名换成你自己的:
# 基于 apt 的发行版:拷进去之后刷新 sudo cp rootCA.crt /usr/local/share/ca-certificates/demo-root.crt sudo update-ca-certificates # 基于 rpm 的发行版 sudo cp rootCA.crt /etc/pki/ca-trust/source/anchors/demo-root.crt sudo update-ca-trust extract # macOS 系统钥匙串 sudo security add-trusted-cert -d -r trustRoot \ -k /Library/Keychains/System.keychain rootCA.crt # Windows,管理员权限执行 certutil -addstore -f Root rootCA.crt # JDK 自带的 cacerts,JDK 9 之后可以直接用 -cacerts keytool -importcert -noprompt -alias demo-root \ -file rootCA.crt -cacerts -storepass changeitLinux 上还有一层容易忘:不少命令行工具走的是$HOME/.pki/nssdb这个独立信任库,而不是系统的/etc/ssl/certs。装完系统级根证书之后 curl 通了、某个图形工具还是报错,八成就是这里的问题:
certutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n "Demo Root" -i rootCA.crt-t "C,,"三个位置分别是「信任 SSL」「信任邮件」「信任代码签名」,C表示作为 CA 信任,后面留空即可。这套命令需要额外装 NSS 工具包,很多精简镜像里没有。
提示:根证书装进信任库之后,它签的所有证书都会被无条件信任。所以根私钥一定要离线保存,别放在签发机上,更别提交进代码仓库。判断标准很朴素——根私钥泄露,等于任何人都能给你签发一张你的内网信任的证书。
4. 部署到服务端:Nginx、Java、Node 三处的挂载参数与验证命令
证书签完、格式也导好了,接下来是挂载。这一块翻车最多的不是配置本身,而是「链的顺序」和「信任库指向」这两件事。下面按三个典型运行时展开,每个都给可抄的配置和验证命令。
4.1 Nginx:fullchain 的拼接顺序决定握手成败
Nginx 的ssl_certificate只接受一个文件,而这个文件里应该按「站点证书 → 中间 CA」的顺序拼接,根 CA 不要放进去。顺序反了,部分客户端会解析失败;把根 CA 也塞进去,会让每次握手多传一个证书,浪费带宽不说,还会让部分严格实现的客户端认为链有问题。
# 顺序不能反:叶子在前,中间 CA 在后,根证书不参与 cat app.demo.internal.crt issuingCA.crt > fullchain.crt对应的 Nginx 配置:
server { listen 443 ssl; server_name app.demo.internal; ssl_certificate /etc/nginx/certs/fullchain.crt; ssl_certificate_key /etc/nginx/certs/app.demo.internal.key; # 需要双向认证时打开:用它校验客户端证书 ssl_client_certificate /etc/nginx/certs/rootCA.crt; ssl_verify_client optional; ssl_protocols TLSv1.2 TLSv1.3; ssl_session_cache shared:SSL:10m; location / { proxy_pass http://127.0.0.1:8080; } }几个参数值得展开。ssl_client_certificate指定的是「用来校验客户端证书的那张 CA」,这里放根证书或中间 CA 都可以,取决于客户端证书是谁签的。ssl_verify_client optional表示「客户端给了证书就校验,不给也放行」,需要强制双向认证时改成on,此时没带证书的请求会直接被拒。私钥文件权限设成600、属主改成 Nginx 的运行用户,否则启动时会报cannot load certificate key——这个报错信息很误导,实际上不是文件损坏,而是读不到。
改完配置用nginx -t检查语法,-t会顺带校验证书和私钥是否配对,这一步能挡掉大半低级错误。
4.2 Java:keystore 与 truststore 是两件事
Java 生态里最容易搞混的一对概念:keystore 存的是「自己的证书和私钥」,truststore 存的是「我信任哪些 CA」。服务端要配 keystore,客户端要配 truststore,配置项分别是javax.net.ssl.keyStore和javax.net.ssl.trustStore,写反了就是一个能起、一个连不上。
服务端侧,直接用前面导出的 p12:
java -Djavax.net.ssl.keyStore=/etc/app/app-demo.p12 \ -Djavax.net.ssl.keyStoreType=PKCS12 \ -Djavax.net.ssl.keyStorePassword=changeit \ -jar app.jar客户端侧要单独准备一个 truststore,把根证书导进去:
keytool -importcert -noprompt -alias demo-root \ -file rootCA.crt \ -keystore truststore.p12 -storetype PKCS12 -storepass changeit运行时指向它:
java -Djavax.net.ssl.trustStore=/etc/app/truststore.p12 \ -Djavax.net.ssl.trustStoreType=PKCS12 \ -Djavax.net.ssl.trustStorePassword=changeit \ -jar client.jar一个常见误区是「把根证书导进 JDK 自带的 cacerts 就万事大吉」。这确实能让所有跑在这个 JDK 上的程序都信任这张根,但 JDK 升级会覆盖 cacerts,而且容器镜像重建之后改动全丢。稳妥做法是用-Djavax.net.ssl.trustStore显式指向项目自带的 truststore 文件,跟着应用一起发布。当然,临时排查时直接导 cacerts 最快,等定位完问题再改成显式指向也不迟。
4.3 Node 与 Python 客户端:显式指定 CA
Node 的服务端配置和 Nginx 思路一致,只是把证书路径塞进 options:
const https = require('https'); const fs = require('fs'); const options = { key: fs.readFileSync('/etc/app/app.demo.internal.key'), // 这里同样要的是 fullchain,不是单张叶子证书 cert: fs.readFileSync('/etc/app/fullchain.crt'), ca: fs.readFileSync('/etc/app/rootCA.crt'), // 双向认证开关:要求客户端出示证书 requestCert: true, rejectUnauthorized: true, minVersion: 'TLSv1.2', }; https.createServer(options, (req, res) => { const cert = req.socket.getPeerCertificate(); res.end(`hello ${cert.subject?.CN || 'anonymous'}`); }).listen(8443);Node 客户端连自签服务时,最简单的做法是环境变量NODE_EXTRA_CA_CERTS=/etc/app/rootCA.crt node client.js,进程启动时会把这张根追加进默认信任列表,代码里不用改。注意这个变量只在进程启动时读取一次,运行中改文件不生效,重启才行。
Python 的 requests 库反过来,最省事的是代码里显式传verify:
import requests # verify 指向根证书文件,requests 会用它校验整条链 resp = requests.get( "https://app.demo.internal/healthz", verify="/etc/app/rootCA.crt", timeout=5, ) print(resp.status_code, resp.text)不想改代码就用环境变量REQUESTS_CA_BUNDLE=/etc/app/rootCA.crt,效果一样。这里有个细节:verify传目录时,该目录下所有证书都会被当成信任根,而且要求文件名是哈希值(openssl rehash生成的那种),直接丢一个rootCA.crt进去是不生效的。传单个文件最不容易出错。
4.4 验证三步:verify、s_client、curl
配置挂好之后别急着让业务方试,自己先跑这三条。第一条验链,第二条验握手,第三条验真实请求。
# 1. 纯离线验链:-untrusted 传入中间 CA openssl verify -CAfile rootCA.crt -untrusted issuingCA.crt app.demo.internal.crt # 2. 连上去看握手,同时校验主机名 openssl s_client -connect app.demo.internal:443 \ -servername app.demo.internal \ -CAfile rootCA.crt \ -verify_hostname app.demo.internal \ -verify_return_error </dev/null # 3. 用 curl 走一次真实请求,-v 看握手细节 curl -v --cacert rootCA.crt https://app.demo.internal/healthz第一条输出app.demo.internal.crt: OK才算链没问题。如果报unable to get local issuer certificate,说明中间 CA 没传对或者链断了;报certificate has expired就去核对-days和系统时间。
第二条重点看两处:depth序列应该是 1(中间 CA)和 0(叶子),以及最后那行Verify return code: 0 (ok)。非零返回码都是明确信号,常见几个对照一下:
| 返回码 | 含义 | 通常原因 |
|---|---|---|
| 0 | 校验通过 | —— |
| 2 | 找不到签发者 | 中间 CA 没发或顺序反了 |
| 10 | 证书已过期 | 有效期设太短或系统时间漂移 |
| 18 | 自签证书不被信任 | 根证书没装进对端的信任库 |
| 20 | 无法获取本地签发者 | 客户端 CA 列表里没有对应根 |
| 62 | 主机名不匹配 | SAN 缺条目或类型写错 |
第三条能跑通,基本就代表业务侧没问题了。curl报错和openssl s_client报错不一致也常见——比如 curl 通了但 Java 客户端不通,那多半是 truststore 的问题,而不是证书本身的问题。
5. 排查与避坑:五条自签证书翻车记录和它们的补救办法
下面这五条都是我实际遇到过、并且事后复盘觉得完全可避免的。每条按「看到什么现象 → 根因在哪 → 怎么修」写,遇到类似症状可以直接对号入座。
5.1 现象:curl 报unable to get local issuer certificate,但根证书明明装了
原因:服务端只发了一张叶子证书,没发中间 CA。客户端拿着叶子证书去信任库里找签发者,找不到中间 CA,链断在中间。根证书装得再对也没用,因为客户端根本没拿到能接到根上的那一环。
解决:检查ssl_certificate指向的是不是 fullchain 文件,以及拼接顺序是不是「叶子在前、中间 CA 在后」。用openssl s_client -showcerts -connect host:443数一下服务端到底回了几张证书,正常应该是两张(叶子 + 中间)。只有一张就是没拼。
5.2 现象:浏览器报ERR_CERT_COMMON_NAME_INVALID,但 openssl 命令行看证书一切正常
原因:证书里只有 CN,没有 SAN 扩展。命令行工具很多只做链校验、不做主机名校验,所以看着「正常」;浏览器是强制走 SAN 的,CN 写了等于白写。
解决:重新签发,扩展文件里补subjectAltName,把所有访问方式(域名、通配符、IP、localhost)全列进去。签完用openssl x509 -in cert.crt -noout -text | grep -A2 "Subject Alternative Name"确认扩展真的写进去了。这一步我每次都会做,因为扩展文件写了但-extfile忘了传的情况太常见。
5.3 现象:昨天还能用的服务,今天全线报证书过期,但签发时间确实是最近
原因:两个方向。一是证书本身有效期设得太短(比如工具默认 30 天),二是签发机或运行环境的时钟漂移,签出来的证书notBefore落在未来或者notAfter落在过去。容器里没同步时间的情况下,这个问题尤其容易冒出来。
解决:先用openssl x509 -in cert.crt -noout -dates把notBefore和notAfter打出来,和当前时间对一遍。如果时间本身没错但只剩几天,说明当初填的有效期太短,重新签一张 397 天的。容器环境顺手检查一下时间同步,别在错误的时钟上签证书——签出来的东西可能当场就是个废证。
5.4 现象:Nginx 起不来,日志提示cannot load certificate key或key values mismatch
原因:前半句多半是权限问题——私钥文件属主不是 Nginx 的运行用户,或者权限位是644被安全策略拒绝;后半句是配错对,证书和私钥不是同一对。还有一种情况是私钥带了口令,启动时需要交互输入,而 systemd 环境没有终端。
解决:chmod 600私钥、chown给运行用户;用openssl x509 -noout -modulus -in cert.crt | openssl md5和openssl rsa -noout -modulus -in cert.key | openssl md5对比两个哈希,不一致就是配错对了。私钥带口令的场景,要么去掉口令(内网可控环境下可接受),要么交给 systemd 的凭据机制管,别在启动脚本里明文写密码。
5.5 现象:Java 客户端连不上,抛PKIX path building failed
原因:Java 不读系统信任库,它只认自己的cacerts或者-Djavax.net.ssl.trustStore指定的文件。系统层面把根证书装得再漂亮,Java 这边没装就是连不上。另一种可能是 p12 里缺中间 CA,Java 建链时同样断。
解决:用keytool -importcert把根证书导进 truststore,运行时用-Djavax.net.ssl.trustStore指过去。导出 p12 时记得带-certfile issuingCA.crt。排查时加-Djavax.net.debug=ssl:handshake跑一次,握手日志里能看到 Java 到底拿了几张证书、在哪一步放弃的。
注意:还有一类「玄学」问题——某些桌面软件用的是自带的证书库,完全不读系统信任库。这种情况下装系统根证书是无效的,得找到那个软件自己的导入入口。排查思路是先确认它读的是哪个库,再决定装哪里,别一股脑往系统里塞。
6. 把自签 CA 用成长期资产:到期巡检脚本与轮换节奏
自签证书最容易被当成一次性消耗品:用完就扔,等到哪天集体过期再手忙脚乱重来一遍。真正常态化的做法,是把这套 CA 当成内部基础设施来维护,给它定好有效期梯度、写好巡检脚本、留好轮换路径。
先定梯度。根 CA 十年、中间 CA 三年、站点证书不超过 397 天,这套配比的好处是轮换节奏错开:站点证书一年换一次,属于日常运维;中间 CA 三年换一次,需要重新签发一批站点证书,属于季度规划;根 CA 十年才动一次,动之前必须保证所有终端的信任库都能同步更新。中间 CA 的有效期一定不能比根 CA 长,站点证书的有效期一定不能比中间 CA 长,这个包含关系搞反了,链会在某个时间点集体失效。
巡检脚本不用复杂,扫一遍证书目录、把剩余天数打出来、快过期的标红就够:
#!/usr/bin/env bash # 扫描目录下所有 .crt,输出剩余有效天数,30 天内的高亮提示 set -euo pipefail WARN_DAYS=30 now=$(date +%s) find "${1:-.}" -name '*.crt' -type f | while read -r crt; do end=$(openssl x509 -in "$crt" -noout -enddate | cut -d= -f2) end_ts=$(date -d "$end" +%s) left=$(( (end_ts - now) / 86400 )) if [ "$left" -le "$WARN_DAYS" ]; then printf '[WARN] %-45s %4d days\n' "$crt" "$left" else printf '[ OK ] %-45s %4d days\n' "$crt" "$left" fi done脚本里用openssl x509 -enddate取到期时间,转成时间戳再算天数,避免各种日期格式差异。把它挂进日常巡检或者 CI 的定时任务里,快到期自动提醒,比在日历上手动记靠谱得多。这类脚本不需要多优雅,能稳定跑、输出能一眼看懂就够了。
再补一个轮换时的细节:换站点证书时不要换私钥,除非有泄露嫌疑。证书和私钥是一对,但同一把私钥可以签很多张证书。保留私钥只换证书,能省掉一整套「私钥分发 + 服务重启 + 权限重设」的流程,风险也小。只有当私钥可能外泄、或者算法要从 RSA 迁到 ECDSA 时,才需要连私钥一起换。
轮换顺序也有讲究:先签新证书、装进服务端、验证通过,再下掉旧证书;根证书换的时候更麻烦,必须保证新旧两张根在信任库里共存一段时间,等所有终端都更新完再删旧的。这个共存窗口给三个月比较稳,短了容易有漏网设备。至于旧根什么时候删——我之前图省事删早过一次,结果一台很久没开机的测试设备上线之后全线报错,查了大半天才想起来是根证书没了。从那以后我每次换根都强制走一遍「新旧
本文还有配套的精品资源,点击获取