OpenSSL 证书 CSR 生成与证书链排错实战:私钥/CSR 匹配、fullchain 拼接与链不完整定位

证书问题为什么总在最尴尬的时候出现

对个人站长来说,HTTPS 证书的坑往往不是「没有证书」,而是「证书看起来装上了,浏览器却报警」。常见症状有三种:一是浏览器地址栏显示「不安全」,点开详情说「证书链不完整」;二是手机端能打开、某些老设备打不开;三是在线检测工具(如 SSL Labs)给了一堆警告,自己却看不懂。这些问题的根源,八成集中在两个环节:CSR / 私钥的生成方式,以及服务器返回的证书链是否完整。

本文不依赖任何图形面板,直接从命令行走一遍完整流程:怎么正确生成私钥和 CSR、怎么验证 CSR 里的域名和私钥是否匹配、怎么确认拿到的证书链是不是 fullchain、以及当浏览器报「链不完整」时怎么一步步定位。掌握了这套方法,无论是用 Let's Encrypt、买商业证书还是自建 CA,都不会再被「证书链」这种名词吓住。

私钥与 CSR:概念先理顺

要申请一张 SSL 证书,标准流程是:先在服务器上生成一对密钥(私钥 + 公钥),然后用私钥对一段包含域名信息的「申请文件」签名,这个申请文件就是 CSR(Certificate Signing Request)。CA(证书颁发机构)拿到 CSR 后,验证你对域名的控制权,签发证书给你。证书里包含你的公钥和域名,由 CA 的私钥签名。

关键点在于:私钥永远不出服务器,交给 CA 的只有 CSR。CSR 里不含私钥,只含公钥和域名。很多人误以为 CSR 是「敏感文件」要小心保管,其实它本来就是设计用来公开传递的。真正要锁死权限的是私钥文件。

生成私钥时,现代环境优先用 ECDSA(椭圆曲线),密钥短、握手快、兼容性也已经足够好。RSA 2048 是保守选择,兼容性最强但握手略慢。如果你的访客主要是现代浏览器和手机,直接用 ECDSA P-256 就够。

# 生成 ECDSA P-256 私钥(推荐)
openssl ecparam -name prime256v1 -genkey -noout -out example.com.key

# 或者生成 RSA 2048 私钥(最大兼容)
openssl genrsa -out example.com.key 2048

# 收紧私钥权限,只有 root 可读
chmod 600 example.com.key

接着用私钥生成 CSR。注意 CN(Common Name)在现代证书里已经不重要了,真正生效的是 SAN(Subject Alternative Name)。所以生成 CSR 时一定要通过配置文件带上 SAN,否则某些 CA 会签出「没有 SAN」的证书,浏览器会直接拒绝。

openssl req -new -key example.com.key -out example.com.csr \
  -subj "/C=CN/ST=Beijing/L=Beijing/O=Personal/CN=example.com" \
  -addext "subjectAltName=DNS:example.com,DNS:www.example.com"

注意 -addext 是 OpenSSL 1.1.1 及以上才支持。如果你的 OpenSSL 版本较老,需要写一个 .cnf 配置文件,在 [v3_req] 段落里加 subjectAltName=DNS:example.com,DNS:www.example.com,然后加 -reqexts v3_req 参数。

验证 CSR:域名对不对、和私钥配不配

提交 CSR 之前,养成「先自检再提交」的习惯,能省掉一次审核失败的等待。用下面的命令读出 CSR 的完整内容:

openssl req -in example.com.csr -noout -text

重点看两处:一是 Subject 里的 CN 是否是你申请的域名;二是 X509v3 Subject Alternative Name 段落是否列出了所有需要覆盖的域名(比如 DNS:example.com, DNS:www.example.com)。如果发现少了 www,趁早重新生成,别等到证书下来才发现不覆盖。

更关键的验证是「CSR 和私钥是否匹配」。这个错误非常隐蔽:你不小心用 A 私钥生成了 CSR,却把 B 私钥配上去了,证书装到 Nginx 后启动会报错 key values mismatch,或者证书能加载但握手失败。用 modulus 对比两条哈希即可:

# 取私钥的 modulus 哈希
openssl rsa -noout -modulus -in example.com.key | openssl md5

# 取 CSR 的 modulus 哈希
openssl req -noout -modulus -in example.com.csr | openssl md5

两个 md5 值必须完全一致。对 ECDSA 密钥,openssl rsa 不适用,改用公钥比对:openssl ec -in example.com.key -pubout 和 openssl req -in example.com.csr -noout -pubkey,把两段公钥输出做 diff,一致就说明匹配。

拿到证书后:先看它是不是 fullchain

CA 签发的证书通常提供两个文件:一个是你的域名证书(只含自己这一张,叫 domain.crt 或 certificate.crt),另一个是中间证书(ca_bundle.crt / chain.crt)。浏览器要的是「叶子证书 + 中间证书」拼在一起的完整链。如果你只把域名证书配进 Nginx,在电脑上可能没事(因为浏览器缓存了中间证书),但手机、新设备、以及各种 API 客户端会直接报「证书链不完整」。这就是「电脑能开、手机打不开」的经典原因。

拼接的顺序很重要:自己的证书在前,中间证书在后。顺序反了同样会报错。

# 拼接完整链:叶子证书在前,中间证书在后
cat domain.crt ca_bundle.crt > fullchain.crt

# 确认链里证书数量(fullchain 一般有 2~3 张)
grep -c "BEGIN CERTIFICATE" fullchain.crt

然后用 openssl 校验这条链:

# 用 fullchain 里的中间证书建立信任链去验证叶子证书
openssl verify -untrusted ca_bundle.crt domain.crt

# 输出 domain.crt: OK 才算通过

另一个更直观的方法是直接连服务器看它「返回了什么链」:

openssl s_client -connect example.com:443 -servername example.com -showcerts < /dev/null

这个命令会打印服务器在 TLS 握手中实际发送的证书链。数一数 s_client 输出里有几个 s: 段(每个 Certificate chain 项对应一张证书),正确情况下应该有 2 张以上(叶子 + 中间)。如果只有 1 张,说明 Nginx 没配 ssl_certificate 指向 fullchain,而是指向了单张证书。握手输出的最后还有一段 Verify return code,为 0 表示验证通过,非 0 时看括号里的原因,最典型的就是 unable to verify the first certificate,也就是缺中间证书。

Nginx 里的正确配置

在 Nginx 的 server 块里,证书配置就两行,关键是 ssl_certificate 指向 fullchain,而 ssl_certificate_key 指向私钥:

server {
    listen 443 ssl;
    server_name example.com www.example.com;

    ssl_certificate     /etc/nginx/ssl/fullchain.crt;
    ssl_certificate_key /etc/nginx/ssl/example.com.key;

    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         HIGH:!aNULL:!MD5;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 10m;
}

很多人会问:用 www.example.com 访问报证书错误怎么办?答案在证书本身的 SAN 里——如果证书的 SAN 同时包含 example.com 和 www.example.com,那么两个域名都能正常握手。这也是前面强调「生成 CSR 时带上所有域名」的原因。如果漏了 www,唯一的补救是重新签发。

证书链排错的标准流程

遇到浏览器提示「证书不受信任」或「链不完整」,按下面这个顺序排查,基本不会走弯路:

第一步,确认服务器实际返回了几张证书。用 openssl s_client -showcerts 数证书数量。只有 1 张 → 是没拼 fullchain,直接修。

第二步,确认链的顺序和内容。把 s_client 输出的所有证书抓出来,逐个看 issuer 和 subject。正确的关系应当是:叶子证书的 issuer = 中间证书的 subject;中间证书的 issuer = 根证书的 subject(根证书一般不在链里发,由客户端预置)。如果发现 issuer 和 subject 对不上,说明拼错了文件。

# 从 fullchain 中拆出每一张证书单独查看
openssl crl2pkcs7 -nocrl -certfile fullchain.crt | \
  openssl pkcs7 -print_certs -noout

第三步,检查是否多余或缺失。有些 CA 会给一个包含根证书的大包,如果你把根证书也拼进 fullchain,会把链路撑大(每次握手多传几 KB),虽然不一定报错但没必要。正确的 fullchain 只包含「叶子 + 中间」,不含根。

第四步,确认 HTTPS 与 HTTP/2、SNI 的配合。如果 Nginx 里同一个 IP 配了多个站点,每个 HTTPS 站点都必须有自己的 server 块和证书,配合 SNI 才能正确路由。漏配的域名会被第一个 server 块兜底,导致「证书是别的域名」的错误提示。

几个真实的高频坑

坑一:只在电脑上测试。桌面浏览器普遍会「补链」(自动下载缺失的中间证书),所以链不完整在电脑上看不出来。一定用 openssl s_client 或手机实测,才能发现真问题。

坑二:私钥权限过松。私钥若被其他用户可读,Nginx 仍能加载,但这是严重的安全隐患。规范是 chmod 600、属主为 root,且不要放进网站可访问目录。如果报 Permission denied,多半是权限或 SELinux 上下文不对。

坑三:证书续期后忘了 reload。用 acme.sh 或 certbot 自动续期,证书文件更新了,但 Nginx 还在用旧证书的内存副本,直到 nginx -s reload 才生效。要在续期钩子里加 reload,否则会「明明续期成功却还是过期」。

坑四:CSR 里的 CN 用了 IP。现代 CA 几乎不再给 IP 地址签发带 SAN 的证书(除非特殊的 IP 证书),用 IP 当 CN 申请一定失败。域名证书就老老实实用域名。

坑五:ECDSA 与 RSA 证书装混。ECDSA 私钥必须配 ECDSA 证书,RSA 私钥必须配 RSA 证书,混装会报 key values mismatch。续期或更换算法时特别容易犯。

小结

证书这件事,说到底就是三个「匹配」:私钥要和 CSR 匹配、CSR 里的域名要和实际访问的域名匹配、服务器返回的证书链要和客户端预置的根证书匹配。三处都对了,浏览器就不会报警。日常运维里最值得养成的习惯,是每次换证书后都跑一遍 openssl s_client -connect ... -showcerts,看清服务器到底发了几张证书、验证返回码是不是 0。这个动作只要十秒,却能挡掉绝大多数「证书看起来没问题但其实有问题」的故障。

Last modification:October 12th, 2026 at 12:24 pm

Leave a Comment